From 3b75e607eb909522a8f5562fb40e44efc72bb66c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 20:18:17 -0700 Subject: [PATCH] feat(server): add reusable auth token for dev worktrees (#8606) --- AGENTS.md | 4 +- apps/server/src/auth/EnvironmentAuth.test.ts | 218 +++++++++++++++++- apps/server/src/auth/EnvironmentAuth.ts | 109 ++++++++- apps/server/src/auth/ReusableDevAuth.ts | 31 +++ apps/server/src/auth/SessionStore.test.ts | 153 +++++++++++- apps/server/src/auth/SessionStore.ts | 71 +++++- apps/server/src/auth/http.test.ts | 137 +++++++++++ apps/server/src/auth/http.ts | 25 +- apps/server/src/cli/config.test.ts | 89 +++++++ apps/server/src/cli/config.ts | 24 ++ apps/server/src/config.ts | 2 + apps/server/src/persistence/AuthSessions.ts | 29 ++- apps/web/src/authBootstrap.test.ts | 139 +++++++++++ apps/web/src/environments/primary/auth.ts | 33 ++- docs/internals/environment-auth.md | 19 ++ docs/operations/development.md | 43 +++- .../client-runtime/src/rpc/session.test.ts | 31 +++ packages/client-runtime/src/rpc/session.ts | 40 +++- scripts/dev-runner.test.ts | 21 ++ scripts/dev-runner.ts | 1 + 20 files changed, 1177 insertions(+), 42 deletions(-) create mode 100644 apps/server/src/auth/ReusableDevAuth.ts create mode 100644 apps/server/src/auth/http.test.ts diff --git a/AGENTS.md b/AGENTS.md index e3b5771d797c550f206a4c27dcb75a59ae0ad540..ccf1fdc1d85c4c94d7d16e0507d7563efe2480db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,8 @@ The most common defect in this repo is a change that works on the path you teste - `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. - `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. - Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. -- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, paste that full URL (token included) in your reply. Do not wire up `tailscale serve` by hand for this, and do not open the URL yourself. -- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. If the token got consumed, mint a fresh one with `node apps/server/src/bin.ts pair` — note it carries standard scopes, while the startup URL carries admin scopes (needed for Settings → Connections management). +- Sharing over the tailnet is three steps: run `vp run dev --share` in the background, wait for the `pairingUrl:` line in its output, then give that full URL to an unpaired browser. Do not wire up `tailscale serve` by hand, open the URL yourself, or consume the user's pairing link. A browser with the reusable dev cookie can use the bare origin. If a normal one-time token was consumed, mint a fresh one with `node apps/server/src/bin.ts pair`. It carries standard scopes, while the startup URL carries admin scopes needed for Connections settings. +- To reuse web dev auth across worktrees, configure one fixed `T3CODE_DEV_AUTH_TOKEN` in the main checkout's gitignored `.env`. The `t3.json` setup links that file into worktrees. Never commit or publish the token or a startup URL. See [Reusable dev credential](docs/operations/development.md#reusable-dev-credential). - Stop what you started, by the PID you tracked. See rule 1. ## Test data diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 028fe53e01913fb05a42ff2a3e368abe6320c473..bfa1b87deb9984255d06d54ec44d1886979b21fb 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -3,9 +3,13 @@ import { AuthAdministrativeScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PersistenceErrors from "../persistence/Errors.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -15,6 +19,8 @@ import * as SessionStore from "./SessionStore.ts"; /** Pinned so dev-mode cookie tests can assert the port-scoped name. */ const TEST_SERVER_PORT = 13_773; +const isPairingCredentialIssueError = Schema.is(PairingGrantStore.PairingCredentialIssueError); +const isPersistenceSqlError = Schema.is(PersistenceErrors.PersistenceSqlError); const makeServerConfigLayer = (overrides?: Partial) => Layer.effect( @@ -33,7 +39,7 @@ const makeServerConfigLayer = (overrides?: Partial) => EnvironmentAuth.layer.pipe( - Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), Layer.provide(ServerEnvironment.identityLayer), Layer.provide(makeServerConfigLayer(overrides)), @@ -72,6 +78,216 @@ const requestMetadata = { }; it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { + it.effect("uses the reusable dev cookie without overriding a normal scoped cookie", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata); + const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] }); + const scopedExchange = yield* serverAuth.createBrowserSession( + pairing.credential, + requestMetadata, + ); + const request = { + cookies: { + [sessions.cookieName]: scopedExchange.sessionToken, + [devExchange.cookieName ?? "missing"]: devExchange.sessionToken, + }, + headers: {}, + } as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + + const authenticated = yield* serverAuth.authenticateHttpRequest(request); + expect(devExchange.cookieName).toMatch(/^t3_dev_session_/); + expect(devExchange.expireNormalCookie).toBe(true); + expect(authenticated.scopes).toEqual(["orchestration:read"]); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("does not fall back to the dev cookie after a normal cookie is rejected", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata); + const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] }); + const scopedExchange = yield* serverAuth.createBrowserSession( + pairing.credential, + requestMetadata, + ); + const scoped = yield* sessions.verify(scopedExchange.sessionToken); + yield* sessions.revoke(scoped.sessionId); + const request = { + cookies: { + [sessions.cookieName]: scopedExchange.sessionToken, + [devExchange.cookieName ?? "missing"]: devExchange.sessionToken, + }, + headers: {}, + } as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + + const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request)); + expect(error._tag).toBe("ServerAuthInvalidCredentialError"); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("does not use the dev cookie when Authorization is invalid or empty", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata); + for (const authorization of ["Bearer invalid", ""] as const) { + const request = { + cookies: { [devExchange.cookieName ?? "missing"]: devExchange.sessionToken }, + headers: { authorization }, + } as unknown as Parameters< + EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] + >[0]; + const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request)); + expect(EnvironmentAuth.isServerAuthCredentialError(error)).toBe(true); + } + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("exchanges the reusable dev token for a local scoped OAuth session", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const exchanged = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + token, + ["orchestration:read"], + requestMetadata, + ); + + expect(exchanged.access_token).not.toBe(token); + expect(exchanged.scope).toBe("orchestration:read"); + const dpop = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + token, + ["orchestration:read"], + requestMetadata, + { proofKeyThumbprint: "test-proof-key" }, + ); + expect(dpop.access_token).not.toBe(token); + expect(dpop.access_token).not.toBe(exchanged.access_token); + expect(dpop.token_type).toBe("DPoP"); + expect(dpop.scope).toBe("orchestration:read"); + + const secondBearer = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + token, + ["orchestration:read"], + requestMetadata, + ); + const firstSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(exchanged.access_token), + ); + const secondSession = yield* serverAuth.authenticateHttpRequest( + makeBearerRequest(secondBearer.access_token), + ); + expect(firstSession.subject).toBe("reusable-dev-token-child"); + expect(secondSession.subject).toBe("reusable-dev-token-child"); + expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token"); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("uses a one-time startup credential after local dev token revocation", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const initial = yield* serverAuth.issueStartupPairingCredential(); + const seeded = yield* sessions.verify(token); + + yield* sessions.revoke(seeded.sessionId); + const recovery = yield* serverAuth.issueStartupPairingCredential(); + + expect(initial.credential).toBe(token); + expect(recovery.credential).not.toBe(token); + expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError"); + expect( + (yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response, + ).toMatchObject({ authenticated: true, scopes: AuthAdministrativeScopes }); + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + + it.effect("keeps the pairing issue error as the immediate recovery failure", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const seeded = yield* sessions.verify(token); + + yield* sessions.revoke(seeded.sessionId); + yield* sql` + CREATE TRIGGER reject_startup_pairing_link + BEFORE INSERT ON auth_pairing_links + BEGIN + SELECT RAISE(ABORT, 'startup pairing insert rejected'); + END + `; + + const error = yield* Effect.flip(serverAuth.issueStartupPairingCredential()); + + expect(error._tag).toBe("ServerAuthPairingLinkCreationError"); + expect(isPairingCredentialIssueError(error.cause)).toBe(true); + if (isPairingCredentialIssueError(error.cause)) { + expect(isPersistenceSqlError(error.cause.cause)).toBe(true); + } + }).pipe( + Effect.provide( + makeEnvironmentAuthLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + ), + ), + ); + it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () => Effect.sync(() => { const error = EnvironmentAuth.toBootstrapExchangeError( diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 964fe6220d6b9977d84c9fa9524583a862539dfe..481ffc2fad644d4a903f5374f27ad4bccc10c89a 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -31,10 +31,12 @@ import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerConfig from "../config.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; +import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; @@ -427,6 +429,8 @@ export class EnvironmentAuth extends Context.Service< { readonly response: AuthBrowserSessionResult; readonly sessionToken: string; + readonly cookieName?: string; + readonly expireNormalCookie?: boolean; }, ServerAuthInvalidCredentialError | ServerAuthInternalError >; @@ -504,6 +508,8 @@ export class EnvironmentAuth extends Context.Service< type BootstrapExchangeResult = { readonly response: AuthBrowserSessionResult; readonly sessionToken: string; + readonly cookieName?: string; + readonly expireNormalCookie?: boolean; }; const AUTHORIZATION_PREFIX = "Bearer "; @@ -599,6 +605,8 @@ export const make = Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; const descriptor = yield* policy.getDescriptor(); + const config = yield* ServerConfig.ServerConfig; + const devAuth = resolveReusableDevAuth(config); const authenticateToken = ( token: string, @@ -630,15 +638,22 @@ export const make = Effect.gen(function* () { const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, ): Effect.Effect => { - const credential = selectRequestCredential( + const selectedCredential = selectRequestCredential( request, sessions.cookieName, sessions.legacyCookieName, ); + const dpopToken = parseDpopToken(request); + const hasAuthorization = request.headers.authorization !== undefined; + const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined; + const credential = + selectedCredential ?? + (!hasAuthorization && devCookieToken !== undefined + ? { token: devCookieToken, source: "dev-cookie" as const } + : undefined); if (!credential?.token) { return Effect.fail(new ServerAuthMissingCredentialError({})); } - const dpopToken = parseDpopToken(request); return authenticateToken(credential.token).pipe( Effect.flatMap((session) => { if (session.proofKeyThumbprint) { @@ -697,8 +712,32 @@ export const make = Effect.gen(function* () { const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( credential, requestMetadata, - ) => - bootstrapCredentials.consume(credential).pipe( + ) => { + if (devAuth?.matches(credential)) { + return sessions.verify(credential).pipe( + mapSessionVerificationErrors, + Effect.flatMap((session) => + DateTime.now.pipe( + Effect.map( + (now) => + ({ + response: { + authenticated: true, + scopes: session.scopes, + sessionMethod: session.method, + expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })), + } satisfies AuthBrowserSessionResult, + sessionToken: credential, + cookieName: devAuth.cookieName, + expireNormalCookie: true, + }) satisfies BootstrapExchangeResult, + ), + ), + ), + Effect.withSpan("EnvironmentAuth.createBrowserSession"), + ); + } + return bootstrapCredentials.consume(credential).pipe( Effect.mapError(toBootstrapExchangeError), Effect.flatMap((grant) => sessions @@ -729,11 +768,42 @@ export const make = Effect.gen(function* () { ), Effect.withSpan("EnvironmentAuth.createBrowserSession"), ); + }; + + type ResolvedBootstrapGrant = Pick< + PairingGrantStore.BootstrapGrant, + "scopes" | "subject" | "label" + > & { + readonly method: PairingGrantStore.BootstrapGrant["method"] | "reusable-dev-token"; + }; + const resolveBootstrapGrant = ( + credential: string, + input?: { readonly proofKeyThumbprint?: string }, + ): Effect.Effect< + ResolvedBootstrapGrant, + ServerAuthInvalidCredentialError | ServerAuthInternalError + > => { + if (!devAuth?.matches(credential)) { + return bootstrapCredentials + .consume(credential, input) + .pipe(Effect.mapError(toBootstrapExchangeError)); + } + return sessions.verify(credential).pipe( + mapSessionVerificationErrors, + Effect.map( + (session) => + ({ + method: "reusable-dev-token", + scopes: session.scopes, + subject: "reusable-dev-token-child", + }) satisfies ResolvedBootstrapGrant, + ), + ); + }; const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => - bootstrapCredentials.consume(credential, input).pipe( - Effect.mapError(toBootstrapExchangeError), + resolveBootstrapGrant(credential, input).pipe( Effect.flatMap((grant) => Effect.gen(function* () { const grantedScopes = requestedScopes ?? grant.scopes; @@ -917,12 +987,33 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential")); const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] = - () => - issuePairingCredentialForSubject({ + () => { + const fallback = issuePairingCredentialForSubject({ scopes: AuthAdministrativeScopes, subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, purpose: "startup", - }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); + }); + if (!devAuth) { + return fallback.pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); + } + return sessions.verify(devAuth.credential).pipe( + Effect.map( + (session) => + ({ + id: session.sessionId, + credential: devAuth.credential, + label: "Reusable dev token", + expiresAt: DateTime.toUtc(session.expiresAt ?? REUSABLE_DEV_SESSION_EXPIRES_AT), + }) satisfies AuthPairingCredentialResult, + ), + Effect.catch((cause) => + SessionStore.isSessionCredentialInvalidError(cause) + ? fallback + : Effect.fail(new ServerAuthPairingLinkCreationError({ cause })), + ), + Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"), + ); + }; const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => listSessions().pipe( diff --git a/apps/server/src/auth/ReusableDevAuth.ts b/apps/server/src/auth/ReusableDevAuth.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b7d59db8da24398a307fe47b031ae0b8f87eae4 --- /dev/null +++ b/apps/server/src/auth/ReusableDevAuth.ts @@ -0,0 +1,31 @@ +import * as NodeCrypto from "node:crypto"; +import { AuthSessionId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Redacted from "effect/Redacted"; + +import type { ServerConfig } from "../config.ts"; + +export const REUSABLE_DEV_SESSION_PREFIX = "dev-auth-"; +// The database schema requires an expiry for a configured token with no normal session TTL. +export const REUSABLE_DEV_SESSION_EXPIRES_AT = DateTime.makeUnsafe("9999-12-31T23:59:59.999Z"); + +export function resolveReusableDevAuth( + config: Pick, +) { + if (config.mode !== "web" || config.devUrl === undefined || config.devAuthToken === undefined) { + return undefined; + } + const token = config.devAuthToken; + if (Redacted.value(token).length === 0) { + return undefined; + } + const hash = NodeCrypto.createHash("sha256").update(Redacted.value(token)).digest(); + const tokenId = hash.toString("hex"); + return { + credential: Redacted.value(token), + sessionId: AuthSessionId.make(`${REUSABLE_DEV_SESSION_PREFIX}${tokenId}`), + cookieName: `t3_dev_session_${tokenId}`, + matches: (credential: string) => + NodeCrypto.timingSafeEqual(hash, NodeCrypto.createHash("sha256").update(credential).digest()), + }; +} diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1e2d5c60e9c96212fecab487590d9ff82de777ff..72f91e21d16fca0d8182435ee87d71ff94d2fff7 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -3,9 +3,11 @@ import { EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Redacted from "effect/Redacted"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -13,7 +15,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; -import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { + makeSqlitePersistenceLive, + SqlitePersistenceMemory, +} from "../persistence/Layers/Sqlite.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as SessionStore from "./SessionStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -54,6 +59,32 @@ const relaySessionInput = { client: { label: "Relay desktop", deviceType: "desktop" }, } as const; +const makeDiskSessionStoreLayer = Effect.fn("makeDiskSessionStoreLayer")(function* ( + baseDir: string, + token?: string, +) { + const devUrl = new URL("http://127.0.0.1:5173"); + const paths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, { + baseDirIsExplicit: true, + }); + yield* ServerConfig.ensureServerDirectories(paths); + const persistence = makeSqlitePersistenceLive(paths.dbPath); + return SessionStore.layer.pipe( + Layer.provide(persistence), + Layer.provide(ServerSecretStore.layer), + Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make(baseDir))), + Layer.provide( + makeServerConfigLayer({ + ...paths, + baseDir, + mode: "web", + devUrl, + ...(token === undefined ? {} : { devAuthToken: Redacted.make(token) }), + }), + ), + ); +}); + const repositoryFailure = new PersistenceSqlError({ operation: "AuthSessionRepository.getById:query", detail: "sqlite is unavailable", @@ -62,6 +93,7 @@ const repositoryFailure = new PersistenceSqlError({ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, { create: () => Effect.void, createReplacingActive: () => Effect.succeed([]), + createIfAbsent: () => Effect.void, getById: () => Effect.fail(repositoryFailure), listActive: () => Effect.succeed([]), revoke: () => Effect.fail(repositoryFailure), @@ -103,6 +135,125 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { }), ); + it.effect("keeps reusable dev auth local across disk-backed stores and restarts", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const baseA = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-a-" }); + const baseB = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-b-" }); + const layerA = yield* makeDiskSessionStoreLayer(baseA, token); + const fromA = yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(token); + const local = yield* sessions.issue({ subject: "environment-a" }); + const ticket = yield* sessions.issueWebSocketToken(local.sessionId); + yield* sessions.revoke(dev.sessionId); + return { dev, local, ticket }; + }).pipe(Effect.provide(layerA), Effect.scoped); + + const layerB = yield* makeDiskSessionStoreLayer(baseB, token); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(token); + expect(dev.sessionId).toBe(fromA.dev.sessionId); + expect((yield* Effect.flip(sessions.verify(fromA.local.token)))._tag).toBe( + "InvalidSessionTokenSignatureError", + ); + expect((yield* Effect.flip(sessions.verifyWebSocketToken(fromA.ticket.token)))._tag).toBe( + "InvalidWebSocketTokenSignatureError", + ); + }).pipe(Effect.provide(layerB), Effect.scoped); + + const reopenedB = yield* makeDiskSessionStoreLayer(baseB, token); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(token); + expect(dev.sessionId).toBe(fromA.dev.sessionId); + const ticket = yield* sessions.issueWebSocketToken(dev.sessionId); + expect((yield* sessions.verifyWebSocketToken(ticket.token)).sessionId).toBe(dev.sessionId); + }).pipe(Effect.provide(reopenedB), Effect.scoped); + + const reopenedA = yield* makeDiskSessionStoreLayer(baseA, token); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError"); + }).pipe(Effect.provide(reopenedA), Effect.scoped); + }), + ); + + it.effect("invalidates old dev credentials and tickets after rotation or removal", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-rotation-" }); + const oldToken = "old-reusable-dev-auth-token-that-is-long-enough"; + const newToken = "new-reusable-dev-auth-token-that-is-long-enough"; + const initialLayer = yield* makeDiskSessionStoreLayer(baseDir, oldToken); + const old = yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const dev = yield* sessions.verify(oldToken); + const ticket = yield* sessions.issueWebSocketToken(dev.sessionId); + return { dev, ticket }; + }).pipe(Effect.provide(initialLayer), Effect.scoped); + + const rotatedLayer = yield* makeDiskSessionStoreLayer(baseDir, newToken); + const rotated = yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + expect((yield* Effect.flip(sessions.verify(oldToken)))._tag).toBe( + "MalformedSessionTokenError", + ); + expect((yield* Effect.flip(sessions.verifyWebSocketToken(old.ticket.token)))._tag).toBe( + "UnknownWebSocketSessionError", + ); + expect( + (yield* sessions.listActive()).some((row) => row.sessionId === old.dev.sessionId), + ).toBe(true); + const dev = yield* sessions.verify(newToken); + const ticket = yield* sessions.issueWebSocketToken(dev.sessionId); + return { dev, ticket }; + }).pipe(Effect.provide(rotatedLayer), Effect.scoped); + + const removedLayer = yield* makeDiskSessionStoreLayer(baseDir); + yield* Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + expect((yield* Effect.flip(sessions.verify(newToken)))._tag).toBe( + "MalformedSessionTokenError", + ); + expect((yield* Effect.flip(sessions.verifyWebSocketToken(rotated.ticket.token)))._tag).toBe( + "UnknownWebSocketSessionError", + ); + expect( + (yield* sessions.listActive()).some((row) => row.sessionId === rotated.dev.sessionId), + ).toBe(true); + }).pipe(Effect.provide(removedLayer), Effect.scoped); + }), + ); + + it.effect("keeps the reusable token active after normal sessions expire", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const token = "reusable-dev-auth-token-that-is-long-enough"; + const normal = yield* sessions.issue({ subject: "normal-session" }); + + yield* TestClock.adjust(Duration.days(31)); + + expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token"); + expect((yield* Effect.flip(sessions.verify(normal.token)))._tag).toBe( + "SessionTokenExpiredError", + ); + }).pipe( + Effect.provide( + Layer.merge( + makeSessionStoreLayer({ + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"), + }), + TestClock.layer(), + ), + ), + ), + ); + it.effect("issues and verifies signed browser session tokens", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index be8e7627bb204c376f1789f412353b61e9255cf9..1526dd6705d740ee0615fbdf3d720862155d1ba2 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -1,5 +1,6 @@ import { AuthSessionId, + AuthAdministrativeScopes, AuthStandardClientScopes, AuthEnvironmentScopes, type AuthClientMetadata, @@ -24,6 +25,11 @@ import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { + REUSABLE_DEV_SESSION_EXPIRES_AT, + REUSABLE_DEV_SESSION_PREFIX, + resolveReusableDevAuth, +} from "./ReusableDevAuth.ts"; import { base64UrlDecodeUtf8, base64UrlEncode, @@ -416,7 +422,6 @@ export class SessionStore extends Context.Service< const SIGNING_SECRET_NAME = "server-signing-key"; const DEFAULT_SESSION_TTL = Duration.days(30); const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); - const SessionClaims = Schema.Struct({ v: Schema.Literal(1), kind: Schema.Literal("session"), @@ -492,6 +497,31 @@ export const make = Effect.gen(function* () { } as const; const cookieName = resolveSessionCookieName(cookieInput); const legacyCookieName = resolveLegacySessionCookieName(cookieInput); + const devAuth = resolveReusableDevAuth(serverConfig); + if (devAuth) { + yield* authSessions + .createIfAbsent({ + sessionId: devAuth.sessionId, + subject: "reusable-dev-token", + scopes: AuthAdministrativeScopes, + method: "browser-session-cookie", + client: { + label: "Reusable dev token", + ipAddress: null, + userAgent: null, + deviceType: "unknown", + os: null, + browser: null, + }, + issuedAt: yield* DateTime.now, + expiresAt: REUSABLE_DEV_SESSION_EXPIRES_AT, + }) + .pipe( + Effect.mapError( + (cause) => new SessionCredentialIssueError({ sessionId: devAuth.sessionId, cause }), + ), + ); + } const emitUpsert = (clientSession: AuthClientSession) => PubSub.publish(changesPubSub, { @@ -717,6 +747,42 @@ export const make = Effect.gen(function* () { const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( function* (token) { + if (devAuth?.matches(token)) { + const row = yield* authSessions + .getById({ sessionId: devAuth.sessionId }) + .pipe( + Effect.mapError( + (cause) => + new SessionCredentialVerificationError({ sessionId: devAuth.sessionId, cause }), + ), + ); + if (Option.isNone(row)) { + return yield* new UnknownSessionTokenError({ sessionId: devAuth.sessionId }); + } + if (row.value.revokedAt !== null) { + return yield* new SessionTokenRevokedError({ + sessionId: devAuth.sessionId, + revokedAt: row.value.revokedAt, + }); + } + const observedAt = yield* DateTime.now; + if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { + return yield* new SessionTokenExpiredError({ + sessionId: devAuth.sessionId, + expiresAt: row.value.expiresAt, + observedAt, + }); + } + return { + sessionId: row.value.sessionId, + token, + method: row.value.method, + client: toClientMetadata(row.value.client), + expiresAt: row.value.expiresAt, + subject: row.value.subject, + scopes: row.value.scopes, + } satisfies VerifiedSession; + } const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { return yield* new MalformedSessionTokenError({}); @@ -829,6 +895,9 @@ export const make = Effect.gen(function* () { const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), ); + if (claims.sid.startsWith(REUSABLE_DEV_SESSION_PREFIX) && claims.sid !== devAuth?.sessionId) { + return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid }); + } const observedAt = yield* DateTime.now; const expiresAt = DateTime.make(claims.exp); diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d53ee088376cd2f8cb3abf7da27c69f7e66dc54 --- /dev/null +++ b/apps/server/src/auth/http.test.ts @@ -0,0 +1,137 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentHttpApi } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Etag from "effect/unstable/http/Etag"; +import * as HttpPlatform from "effect/unstable/http/HttpPlatform"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; + +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./http.ts"; + +const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough"; +class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {} + +const configLayer = Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return { + ...config, + mode: "web", + devUrl: new URL("http://127.0.0.1:5173"), + devAuthToken: Redacted.make(DEV_TOKEN), + } satisfies ServerConfig.ServerConfig["Service"]; + }), +).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-http-test-" }))); + +const environmentAuthLayer = EnvironmentAuth.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerEnvironment.identityLayer), + Layer.provide(configLayer), +); +const routesLayer = HttpApiBuilder.layer(AuthTestApi).pipe( + Layer.provide(authHttpApiLayer), + Layer.provide(environmentAuthenticatedAuthLayer), + Layer.provideMerge(environmentAuthLayer), + Layer.provide(configLayer), + Layer.provideMerge( + HttpPlatform.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Etag.layerWeak), + ), + ), + Layer.provide(NodeServices.layer), +); + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const postJson = (path: string, body: unknown, headers?: Readonly>) => + new Request(`http://127.0.0.1${path}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: encodeJson(body), + }); + +it.effect("sets the selected browser session cookies through the HTTP route", () => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({ + get: () => Effect.succeed(Option.none()), + set: () => Effect.void, + create: () => Effect.void, + getOrCreateRandom: () => Effect.die("Not used by these routes."), + remove: () => Effect.void, + }); + const requestContext = Context.make(Crypto.Crypto, crypto).pipe( + Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore), + ); + return yield* Effect.acquireUseRelease( + Effect.sync( + () => + [ + HttpRouter.toWebHandler(routesLayer, { disableLogger: true }), + HttpRouter.toWebHandler(routesLayer, { disableLogger: true }), + ] as const, + ), + ([environmentA, environmentB]) => + Effect.tryPromise(async () => { + const devResponse = await environmentA.handler( + postJson("/api/auth/browser-session", { credential: DEV_TOKEN }), + requestContext, + ); + expect(devResponse.status).toBe(200); + const devCookies = devResponse.headers.getSetCookie(); + const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_")); + expect(devCookie).toContain("HttpOnly"); + expect(devCookie).toContain(`=${DEV_TOKEN};`); + expect(devCookies).toContainEqual( + expect.stringMatching(/^t3_session_[^=]*=;.*Max-Age=0/), + ); + const devCookieHeader = devCookie?.split(";", 1)[0] ?? ""; + const environmentBSession = await environmentB.handler( + new Request("http://127.0.0.1/api/auth/session", { + headers: { cookie: devCookieHeader }, + }), + requestContext, + ); + expect(environmentBSession.status).toBe(200); + expect(await environmentBSession.json()).toMatchObject({ authenticated: true }); + + const pairingResponse = await environmentA.handler( + postJson( + "/api/auth/pairing-token", + { scopes: ["orchestration:read"] }, + { cookie: devCookieHeader }, + ), + requestContext, + ); + expect(pairingResponse.status).toBe(200); + const pairing = (await pairingResponse.json()) as { credential: string }; + const restrictedResponse = await environmentA.handler( + postJson("/api/auth/browser-session", { credential: pairing.credential }), + requestContext, + ); + expect(restrictedResponse.status).toBe(200); + const restrictedCookies = restrictedResponse.headers.getSetCookie(); + expect(restrictedCookies).toHaveLength(1); + expect(restrictedCookies[0]).toMatch(/^t3_session_/); + expect(restrictedCookies[0]).not.toContain("t3_dev_session_"); + }), + ([environmentA, environmentB]) => + Effect.promise(() => Promise.all([environmentA.dispose(), environmentB.dispose()])), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index b50d6eae9a18f1fcc7c51cefe68dabf7828570c2..0f927580367dd6a4241bd5a47df73ed3daa2382d 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -273,10 +273,27 @@ export const authHttpApiLayer = HttpApiBuilder.group( args.payload.credential, deriveAuthClientMetadata({ request }), ); - yield* appendSessionCookie( - sessions.cookieName, - result.sessionToken, - result.response.expiresAt, + const cookieName = result.cookieName ?? sessions.cookieName; + const selectedCookie = yield* Effect.fromResult( + Cookies.set(Cookies.empty, cookieName, result.sessionToken, { + expires: DateTime.toDate(result.response.expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); + const sessionCookies = result.expireNormalCookie + ? yield* Effect.fromResult( + Cookies.expireCookie(selectedCookie, sessions.cookieName, { + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))) + : selectedCookie; + + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), ); yield* appendCredentialResponseHeaders; return result.response; diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 2267a5cb1cc9e7f7460fec521f93ca6aa9ac6cd2..0c28bce28ae5e20215694bcad69e7567936af543 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -9,6 +9,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { @@ -24,6 +25,7 @@ const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true }); const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); +const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const makeDesktopBootstrap = ( overrides: Partial = {}, @@ -73,6 +75,93 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { ); }); + it.effect("enables a trimmed reusable auth token only for web dev mode", () => + Effect.gen(function* () { + const baseDir = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-" })), + ); + const flags = { + mode: Option.some("web" as const), + port: Option.some(8788), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:5173")), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }; + const configLayer = ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_DEV_AUTH_TOKEN: " reusable-dev-auth-token-that-is-long-enough ", + }, + }), + ); + const web = yield* resolveServerConfig(flags, Option.none()).pipe( + Effect.provide(Layer.mergeAll(configLayer, NetService.layer)), + ); + const desktop = yield* resolveServerConfig( + { ...flags, mode: Option.some("desktop" as const) }, + Option.none(), + ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer))); + + expect(web.devAuthToken).toBeDefined(); + if (web.devAuthToken === undefined) { + return yield* Effect.die("Expected reusable dev auth token."); + } + expect(Redacted.value(web.devAuthToken)).toBe("reusable-dev-auth-token-that-is-long-enough"); + expect(desktop.devAuthToken).toBeUndefined(); + }), + ); + + it.effect("does not expose an invalid reusable auth token", () => + Effect.gen(function* () { + const secret = "short-secret"; + const baseDir = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-invalid-" })), + ); + const flags = { + mode: Option.some("web" as const), + port: Option.some(8788), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.some(new URL("http://127.0.0.1:5173")), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }; + const configLayer = ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { T3CODE_DEV_AUTH_TOKEN: secret } }), + ); + const error = yield* resolveServerConfig(flags, Option.none()).pipe( + Effect.provide(Layer.mergeAll(configLayer, NetService.layer)), + Effect.flip, + ); + const desktop = yield* resolveServerConfig( + { ...flags, mode: Option.some("desktop" as const) }, + Option.none(), + ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer))); + const staticWeb = yield* resolveServerConfig( + { ...flags, devUrl: Option.none() }, + Option.none(), + ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer))); + + expect(String(error)).not.toContain(secret); + const serialized = yield* encodeUnknownJson(error); + expect(serialized).not.toContain(secret); + expect(desktop.devAuthToken).toBeUndefined(); + expect(staticWeb.devAuthToken).toBeUndefined(); + }), + ); + it.effect("falls back to effect/config values when flags are omitted", () => Effect.gen(function* () { const { join } = yield* Path.Path; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 847edbbc4fe085fe05bb6eb1bcd55b2a5a903f42..1759b4b0383006923e13278f0f0a33b7f6a21dce 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem"; import * as LogLevel from "effect/LogLevel"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; @@ -141,6 +142,26 @@ const EnvServerConfig = Config.all({ ), }); +const DevAuthTokenConfig = Config.redacted("T3CODE_DEV_AUTH_TOKEN").pipe( + Config.map((token) => Redacted.make(Redacted.value(token).trim())), + Config.mapOrFail((token) => + Redacted.value(token).length === 0 || Redacted.value(token).length >= 32 + ? Effect.succeed(token) + : Effect.fail( + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: "T3CODE_DEV_AUTH_TOKEN must contain at least 32 characters.", + }), + ), + ), + ), + ), + Config.option, + Config.map(Option.filter((token) => Redacted.value(token).length > 0)), + Config.map(Option.getOrUndefined), +); + export interface CliServerFlags { readonly mode: Option.Option; readonly port: Option.Option; @@ -268,6 +289,8 @@ export const resolveServerConfig = ( resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)), () => undefined, ); + const devAuthToken = + mode === "web" && devUrl !== undefined ? yield* DevAuthTokenConfig : undefined; const explicitBaseDir = resolveOptionPrecedence( normalizedFlags.baseDir, Option.fromUndefinedOr(env.t3Home), @@ -373,6 +396,7 @@ export const resolveServerConfig = ( host, staticDir, devUrl, + ...(devAuthToken === undefined ? {} : { devAuthToken }), devAllowedOrigins: env.devAllowedOrigins, noBrowser, startupPresentation, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 1486f6b40a2af95db83f91c2d6feb10fd92b12ab..b0544ef30aeb4c1b39acde82363750c016cf2e8a 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -13,6 +13,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as LogLevel from "effect/LogLevel"; import * as Path from "effect/Path"; +import type * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; @@ -79,6 +80,7 @@ export class ServerConfig extends Context.Service< readonly baseDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; + readonly devAuthToken?: Redacted.Redacted | undefined; readonly devAllowedOrigins: ReadonlyArray; readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 37db33556e230e4247fc84234b409092ed733f2f..836b1f8f2e3022756c04a6a390d902d0e1243553 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -107,6 +107,9 @@ export class AuthSessionRepository extends Context.Service< readonly createReplacingActive: ( input: CreateReplacingActiveAuthSessionInput, ) => Effect.Effect, AuthSessionRepositoryError>; + readonly createIfAbsent: ( + input: CreateAuthSessionInput, + ) => Effect.Effect; readonly getById: ( input: GetAuthSessionByIdInput, ) => Effect.Effect, AuthSessionRepositoryError>; @@ -204,10 +207,11 @@ function toPersistenceSqlOrDecodeError( export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const createSessionRow = SqlSchema.void({ - Request: CreateAuthSessionInput, - execute: (input) => - sql` + const insertSessionRow = (ignoreExisting: boolean) => + SqlSchema.void({ + Request: CreateAuthSessionInput, + execute: (input) => + sql` INSERT INTO auth_sessions ( session_id, subject, @@ -238,8 +242,11 @@ export const make = Effect.gen(function* () { ${input.expiresAt}, NULL ) + ${ignoreExisting ? sql`ON CONFLICT(session_id) DO NOTHING` : sql``} `, - }); + }); + const createSessionRow = insertSessionRow(false); + const createSessionRowIfAbsent = insertSessionRow(true); const getSessionRowById = SqlSchema.findOneOption({ Request: GetAuthSessionByIdInput, @@ -393,6 +400,17 @@ export const make = Effect.gen(function* () { ), ); + const createIfAbsent: AuthSessionRepository["Service"]["createIfAbsent"] = (input) => + createSessionRowIfAbsent(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.createIfAbsent:query", + "AuthSessionRepository.createIfAbsent:encodeRequest", + { sessionId: input.sessionId }, + ), + ), + ); + const getById: AuthSessionRepository["Service"]["getById"] = (input) => getSessionRowById(input).pipe( Effect.mapError( @@ -493,6 +511,7 @@ export const make = Effect.gen(function* () { return { create, createReplacingActive, + createIfAbsent, getById, listActive, revoke, diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index dfe2b51d0400412c812e2dd643c2372a1fb3c8a4..cac54fe1ec8485c8ca2af18951f0c7e63778e870 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -506,6 +506,145 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(1); }); + it("exchanges a URL token when the browser already has a session", async () => { + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + const testWindow = installTestBrowser("http://localhost/#token=reusable-token"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + + expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); + expect(testWindow.location.hash).toBe(""); + }); + + it("exchanges an explicit pair link after caching an authenticated state", async () => { + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + const testWindow = installTestBrowser("http://localhost/"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + testWindow.location = new URL("http://localhost/pair#token=reusable-token"); + + await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); + + expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); + expect(testApi.calls.session).toBe(3); + }); + + it("makes later callers wait for a URL token that arrives during bootstrap", async () => { + let releaseExchange!: () => void; + const exchangeRelease = new Promise((resolve) => { + releaseExchange = resolve; + }); + let markExchangeStarted!: () => void; + const exchangeStarted = new Promise((resolve) => { + markExchangeStarted = resolve; + }); + const nextSession = sequence( + authenticatedSession(LOOPBACK_AUTH), + authenticatedSession(LOOPBACK_AUTH), + ); + const testApi = await installAuthApi({ + session: nextSession, + browserSession: () => { + markExchangeStarted(); + return Effect.promise(() => exchangeRelease).pipe( + Effect.andThen( + Effect.fail( + new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-rejected-queued-credential", + }), + ), + ), + ); + }, + }); + const testWindow = installTestBrowser("http://localhost/"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const initialBootstrap = resolveInitialServerAuthGateState(); + testWindow.location = new URL("http://localhost/pair#token=reusable-token"); + const explicitPairing = resolveInitialServerAuthGateState(); + const laterCaller = resolveInitialServerAuthGateState(); + let laterCallerSettled = false; + void laterCaller.then(() => { + laterCallerSettled = true; + }); + + try { + await expect(initialBootstrap).resolves.toEqual({ status: "authenticated" }); + await exchangeStarted; + expect(laterCallerSettled).toBe(false); + } finally { + releaseExchange(); + } + + const rejectedState = { + status: "requires-auth", + auth: LOOPBACK_AUTH, + errorMessage: "Invalid pairing token. Check the token and try again.", + } as const; + await expect(explicitPairing).resolves.toEqual(rejectedState); + await expect(laterCaller).resolves.toEqual(rejectedState); + expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]); + expect(testApi.calls.session).toBe(2); + }); + + it("does not exchange a token during an ordinary authenticated load", async () => { + const testApi = await installAuthApi({ + session: () => authenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + + expect(testApi.calls.browserSession).toEqual([]); + }); + + it("reports a rejected URL token without caching false success", async () => { + const cause = new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-url-credential", + }); + const nextSession = sequence( + authenticatedSession(LOOPBACK_AUTH), + unauthenticatedSession(LOOPBACK_AUTH), + ); + const testApi = await installAuthApi({ + session: nextSession, + browserSession: () => Effect.fail(cause), + }); + installTestBrowser("http://localhost/#token=rejected-token"); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + errorMessage: "Invalid pairing token. Check the token and try again.", + }); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: LOOPBACK_AUTH, + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "rejected-token" }]); + }); + it("creates a pairing credential from the authenticated auth endpoint", async () => { const testApi = await installAuthApi({ pairingCredential: (payload) => diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 2ceacd1a2cb0bf03ab07b38b69f4f2c1e8f1fc9a..ce06fbf5da05a39bea08a92832813ef83d1fb339 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -307,13 +307,13 @@ function isTransientBootstrapError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } -async function bootstrapServerAuth(): Promise { - const bootstrapCredential = getDesktopBootstrapCredential(); +async function bootstrapServerAuth(urlCredential: string | null): Promise { const currentSession = await fetchSessionState(); - if (currentSession.authenticated) { + if (currentSession.authenticated && !urlCredential) { return { status: "authenticated" }; } + const bootstrapCredential = urlCredential ?? getDesktopBootstrapCredential(); if (!bootstrapCredential) { return { status: "requires-auth", @@ -428,19 +428,32 @@ export async function revokeOtherServerClientSessions(): Promise { } export async function resolveInitialServerAuthGateState(): Promise { - if (resolvedAuthenticatedGateState?.status === "authenticated") { - return resolvedAuthenticatedGateState; - } + const urlCredential = takePairingTokenFromUrl(); + const previousPromise = bootstrapPromise; + if (urlCredential) { + resolvedAuthenticatedGateState = null; + } else { + if (previousPromise) { + return previousPromise; + } - if (bootstrapPromise) { - return bootstrapPromise; + if (resolvedAuthenticatedGateState?.status === "authenticated") { + return resolvedAuthenticatedGateState; + } } - const nextPromise = bootstrapServerAuth(); + const nextPromise = previousPromise + ? previousPromise + .catch(() => undefined) + .then(() => { + resolvedAuthenticatedGateState = null; + return bootstrapServerAuth(urlCredential); + }) + : bootstrapServerAuth(urlCredential); bootstrapPromise = nextPromise; return nextPromise .then((result) => { - if (result.status === "authenticated") { + if (bootstrapPromise === nextPromise && result.status === "authenticated") { resolvedAuthenticatedGateState = result; } return result; diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index dc43b4d45c03788e60f485db08b139a514be3a80..2428783ccc56531e1281ef056ed3b216539210e4 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -37,6 +37,25 @@ transaction](../../apps/server/src/persistence/AuthSessions.ts); a failed replacement must leave the old credential usable. Pairing and browser sessions do not follow this replacement rule. +### Reusable dev credential + +Web development environments can accept one `T3CODE_DEV_AUTH_TOKEN` across +worktrees and ports on one hostname. The token and startup URLs that contain it +grant administrative access. Desktop and non-development servers ignore it. See +the [development runbook](../operations/development.md#reusable-dev-credential) +for setup. + +Each environment hashes the value and seeds its own database record at startup. +Environments do not share SQLite data, signing keys, environment IDs, session +records, pairing grants, or revocation state. Local revocation persists after +restart and does not affect another worktree. Removing or rotating the value +and restarting invalidates the old credential and its WebSocket tickets. + +Normal credentials keep precedence. A rejected normal credential never falls +back to the reusable credential. OAuth exchanges create ordinary local bearer +or DPoP children with normal expiry and revocation. The reusable cookie expires +after 30 days. + ## The environment is the filesystem boundary Projects are organizational boundaries, not filesystem sandboxes. diff --git a/docs/operations/development.md b/docs/operations/development.md index f913a8100e75716ec990c3658d7d2e516853c268..505a59e198187b009464d866bfb4f66f681fade1 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -10,7 +10,7 @@ vp i vp run dev ``` -Open the one-time pairing URL printed by the dev runner. The bare origin does not authenticate +Open the pairing URL printed by the dev runner. The bare origin does not authenticate a new browser. Prefer a container? See [Dev container](../internals/devcontainer.md) for VS Code and Codespaces setup. @@ -56,6 +56,47 @@ when changing this setup: The workarounds live in the [web entry](../../apps/web/src/bootstrap.ts) and [Tailwind plugin](../../apps/web/vite/tailwind.ts). +#### Reusable dev credential + +Use this only on a hostname where you trust every service. Browsers send cookies to all ports +on that hostname. Any service you visit there can receive the reusable admin credential, +including services unrelated to T3 Code. If you run untrusted services on that hostname, keep +normal per-environment pairing instead. + +To use one browser profile across web dev worktrees on the same hostname, generate one fixed +value once: + +```sh +openssl rand -hex 32 +``` + +Put that value in the main checkout's gitignored `.env`: + +```dotenv +T3CODE_DEV_AUTH_TOKEN= +``` + +The `t3.json` Setup Worktree commands on Unix and Windows link that file to each worktree's +`.env`. The dev runner reads repository env files at startup. `.env.local` and inherited process +environment values override `.env`, so no per-worktree export is needed after setup. + +For a manual worktree or launcher without that link, export the same fixed value instead: + +```sh +export T3CODE_DEV_AUTH_TOKEN="" +``` + +Do not generate a new value at startup. Start or restart `vp run dev --share` after configuration, +then open its printed startup pairing URL once per browser profile on that hostname. Later web dev +servers on the same hostname accept the shared cookie across ports. The cookie expires after 30 +days. Reload an old tab if its URL now serves a replacement environment. + +The token and startup pairing URLs are reusable administrative secrets. Never put them in a +commit, pull request, or public output. Every server still seeds its own auth database record at +startup and keeps its own SQLite data, signing key, and revocation state. Desktop and non-dev +servers ignore the value. See [environment authentication](../internals/environment-auth.md#reusable-dev-credential) +for the security model. + ## Checks Run checks for the files and packages you changed: diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 8fe76e6181b83165eea4a5113feecef688618541..4687b84c359fbc4a051b6f31f975f3369c869cc4 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -1057,6 +1057,37 @@ describe("RpcSessionFactory", () => { ), ); + it.effect("rejects a server config for a different environment", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); + const configFiber = yield* session + .subscribeServerConfig({}) + .pipe(Stream.runHead, Effect.flip, Effect.forkChild); + const customConfigFiber = yield* session + .subscribeServerConfig({ environmentThemes: true }) + .pipe(Stream.runHead, Effect.flip, Effect.forkChild); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket, { + ...ENCODED_SERVER_CONFIG, + environment: { + ...ENCODED_SERVER_CONFIG.environment, + environmentId: "environment-2", + }, + }); + + const error = yield* Fiber.join(readyFiber); + expect(error).toMatchObject({ + reason: "configuration", + message: "Connected environment environment-2 does not match environment-1.", + }); + expect((yield* Fiber.join(configFiber))._tag).toBe("RpcClientError"); + expect((yield* Fiber.join(customConfigFiber))._tag).toBe("RpcClientError"); + }), + ); + it.effect("tolerates two missed pong windows before closing the session", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 3e353f6be4df725be97f3c4c7a30a6cae055bbe9..252bf0242236ddde29911865b4bb6a8db9e6518e 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -40,6 +40,7 @@ import { type ServerConfigProjection, withoutEnvironmentThemes, } from "../state/serverConfigProjection.ts"; +import { environmentMismatchError } from "../connection/errors.ts"; const SOCKET_OPEN_TIMEOUT = "15 seconds"; @@ -289,14 +290,20 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( Effect.mapError(mapRpcError), Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)), ), - ).pipe(Effect.withSpan("environment.initialSync")); + ).pipe( + Effect.flatMap((config) => + config.environment.environmentId === connection.environmentId + ? Effect.succeed(config) + : environmentMismatchError({ + expected: connection.environmentId, + actual: config.environment.environmentId, + }), + ), + Effect.withSpan("environment.initialSync"), + ); const serverConfigEvents = Stream.unwrap( Effect.gen(function* () { const subscription = yield* PubSub.subscribe(serverConfigUpdates); - yield* Effect.raceFirst( - Deferred.await(initialConfigDeferred).pipe(Effect.asVoid), - Deferred.await(serverConfigExit), - ); const snapshot = yield* Ref.get(serverConfigState); if (Option.isNone(snapshot)) { return Stream.empty; @@ -336,10 +343,27 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( ); }), ); + const validatedInitialConfig = initialConfig.pipe( + Effect.mapError( + (cause) => + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: `${connection.label} config subscription failed.`, + cause, + }), + }), + ), + ); const subscribeServerConfig = (input: ServerConfigSubscriptionInput) => - Equal.equals(input, serverConfigInput) - ? serverConfigEvents - : protocolClient[WS_METHODS.subscribeServerConfig](input); + Stream.unwrap( + validatedInitialConfig.pipe( + Effect.as( + Equal.equals(input, serverConfigInput) + ? serverConfigEvents + : protocolClient[WS_METHODS.subscribeServerConfig](input), + ), + ), + ); const probe = initialConfig.pipe( Effect.flatMap((config) => (config.environment.capabilities.connectionProbe === true diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 1a63d8d74edf38f19334dcff31dfd8d215ec207f..5a270386a30dafcd4a88a736b5836c6e73357df3 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -152,6 +152,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); describe("createDevRunnerEnv", () => { + it.effect("forwards the reusable auth token to web dev and removes it for desktop", () => + Effect.gen(function* () { + const input = { + baseEnv: { T3CODE_DEV_AUTH_TOKEN: "reusable-dev-auth-token-that-is-long-enough" }, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + } as const; + const web = yield* createDevRunnerEnv({ ...input, mode: "dev" }); + const desktop = yield* createDevRunnerEnv({ ...input, mode: "dev:desktop" }); + + assert.equal(web.T3CODE_DEV_AUTH_TOKEN, input.baseEnv.T3CODE_DEV_AUTH_TOKEN); + assert.equal(desktop.T3CODE_DEV_AUTH_TOKEN, undefined); + }), + ); it.effect("leaves the shared home implicit and disables browser auto-open", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index c4062955e9d56c84c3c574db35ca5b34243e10b3..5b16e9f90079e7e11111963d6997e20e7c836c4c 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -388,6 +388,7 @@ export function createDevRunnerEnv({ delete output.T3CODE_MODE; delete output.T3CODE_NO_BROWSER; delete output.T3CODE_HOST; + delete output.T3CODE_DEV_AUTH_TOKEN; } if (!isDesktopMode && host !== undefined) { -- 2.54.0