authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-13 20:18:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 20:18:17-07:00
log3b75e607eb909522a8f5562fb40e44efc72bb66c
treec94567b1561dd19dde5173ee8730ceadd5d9c4a6
parent564719165e9131ab2aa55e9c805b860a415690da
signature Signed by PGP key B5690EEEBB952194

feat(server): add reusable auth token for dev worktrees (#8606)


20 files changed, 1177 insertions(+), 42 deletions(-)

AGENTS.md+2-2
...@@ -79,8 +79,8 @@ The most common defect in this repo is a change that works on the path you teste...@@ -79,8 +79,8 @@ The most common defect in this repo is a change that works on the path you teste
79- `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run.79- `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run.
80- `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.80- `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.
81- 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.81- 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.
82- 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.82- 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.
83- 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).83- 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).
84- Stop what you started, by the PID you tracked. See rule 1.84- Stop what you started, by the PID you tracked. See rule 1.
8585
86## Test data86## Test data
apps/server/src/auth/EnvironmentAuth.test.ts+217-1
...@@ -3,9 +3,13 @@ import { AuthAdministrativeScopes } from "@t3tools/contracts";...@@ -3,9 +3,13 @@ import { AuthAdministrativeScopes } from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import * as SqlClient from "effect/unstable/sql/SqlClient";
69
7import * as ServerConfig from "../config.ts";10import * as ServerConfig from "../config.ts";
8import * as ServerEnvironment from "../environment/ServerEnvironment.ts";11import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
12import * as PersistenceErrors from "../persistence/Errors.ts";
9import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";13import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
10import * as PairingGrantStore from "./PairingGrantStore.ts";14import * as PairingGrantStore from "./PairingGrantStore.ts";
11import * as EnvironmentAuth from "./EnvironmentAuth.ts";15import * as EnvironmentAuth from "./EnvironmentAuth.ts";
...@@ -15,6 +19,8 @@ import * as SessionStore from "./SessionStore.ts";...@@ -15,6 +19,8 @@ import * as SessionStore from "./SessionStore.ts";
1519
16/** Pinned so dev-mode cookie tests can assert the port-scoped name. */20/** Pinned so dev-mode cookie tests can assert the port-scoped name. */
17const TEST_SERVER_PORT = 13_773;21const TEST_SERVER_PORT = 13_773;
22const isPairingCredentialIssueError = Schema.is(PairingGrantStore.PairingCredentialIssueError);
23const isPersistenceSqlError = Schema.is(PersistenceErrors.PersistenceSqlError);
1824
19const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>25const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
20 Layer.effect(26 Layer.effect(
...@@ -33,7 +39,7 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se...@@ -33,7 +39,7 @@ const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Se
3339
34const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>40const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
35 EnvironmentAuth.layer.pipe(41 EnvironmentAuth.layer.pipe(
36 Layer.provide(SqlitePersistenceMemory),42 Layer.provideMerge(SqlitePersistenceMemory),
37 Layer.provide(ServerSecretStore.layer),43 Layer.provide(ServerSecretStore.layer),
38 Layer.provide(ServerEnvironment.identityLayer),44 Layer.provide(ServerEnvironment.identityLayer),
39 Layer.provide(makeServerConfigLayer(overrides)),45 Layer.provide(makeServerConfigLayer(overrides)),
...@@ -72,6 +78,216 @@ const requestMetadata = {...@@ -72,6 +78,216 @@ const requestMetadata = {
72};78};
7379
74it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {80it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
81 it.effect("uses the reusable dev cookie without overriding a normal scoped cookie", () =>
82 Effect.gen(function* () {
83 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
84 const sessions = yield* SessionStore.SessionStore;
85 const token = "reusable-dev-auth-token-that-is-long-enough";
86 const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
87 const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] });
88 const scopedExchange = yield* serverAuth.createBrowserSession(
89 pairing.credential,
90 requestMetadata,
91 );
92 const request = {
93 cookies: {
94 [sessions.cookieName]: scopedExchange.sessionToken,
95 [devExchange.cookieName ?? "missing"]: devExchange.sessionToken,
96 },
97 headers: {},
98 } as unknown as Parameters<
99 EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
100 >[0];
101
102 const authenticated = yield* serverAuth.authenticateHttpRequest(request);
103 expect(devExchange.cookieName).toMatch(/^t3_dev_session_/);
104 expect(devExchange.expireNormalCookie).toBe(true);
105 expect(authenticated.scopes).toEqual(["orchestration:read"]);
106 }).pipe(
107 Effect.provide(
108 makeEnvironmentAuthLayer({
109 mode: "web",
110 devUrl: new URL("http://127.0.0.1:5173"),
111 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
112 }),
113 ),
114 ),
115 );
116
117 it.effect("does not fall back to the dev cookie after a normal cookie is rejected", () =>
118 Effect.gen(function* () {
119 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
120 const sessions = yield* SessionStore.SessionStore;
121 const token = "reusable-dev-auth-token-that-is-long-enough";
122 const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
123 const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] });
124 const scopedExchange = yield* serverAuth.createBrowserSession(
125 pairing.credential,
126 requestMetadata,
127 );
128 const scoped = yield* sessions.verify(scopedExchange.sessionToken);
129 yield* sessions.revoke(scoped.sessionId);
130 const request = {
131 cookies: {
132 [sessions.cookieName]: scopedExchange.sessionToken,
133 [devExchange.cookieName ?? "missing"]: devExchange.sessionToken,
134 },
135 headers: {},
136 } as unknown as Parameters<
137 EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
138 >[0];
139
140 const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request));
141 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
142 }).pipe(
143 Effect.provide(
144 makeEnvironmentAuthLayer({
145 mode: "web",
146 devUrl: new URL("http://127.0.0.1:5173"),
147 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
148 }),
149 ),
150 ),
151 );
152
153 it.effect("does not use the dev cookie when Authorization is invalid or empty", () =>
154 Effect.gen(function* () {
155 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
156 const token = "reusable-dev-auth-token-that-is-long-enough";
157 const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
158 for (const authorization of ["Bearer invalid", ""] as const) {
159 const request = {
160 cookies: { [devExchange.cookieName ?? "missing"]: devExchange.sessionToken },
161 headers: { authorization },
162 } as unknown as Parameters<
163 EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
164 >[0];
165 const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request));
166 expect(EnvironmentAuth.isServerAuthCredentialError(error)).toBe(true);
167 }
168 }).pipe(
169 Effect.provide(
170 makeEnvironmentAuthLayer({
171 mode: "web",
172 devUrl: new URL("http://127.0.0.1:5173"),
173 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
174 }),
175 ),
176 ),
177 );
178
179 it.effect("exchanges the reusable dev token for a local scoped OAuth session", () =>
180 Effect.gen(function* () {
181 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
182 const sessions = yield* SessionStore.SessionStore;
183 const token = "reusable-dev-auth-token-that-is-long-enough";
184 const exchanged = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
185 token,
186 ["orchestration:read"],
187 requestMetadata,
188 );
189
190 expect(exchanged.access_token).not.toBe(token);
191 expect(exchanged.scope).toBe("orchestration:read");
192 const dpop = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
193 token,
194 ["orchestration:read"],
195 requestMetadata,
196 { proofKeyThumbprint: "test-proof-key" },
197 );
198 expect(dpop.access_token).not.toBe(token);
199 expect(dpop.access_token).not.toBe(exchanged.access_token);
200 expect(dpop.token_type).toBe("DPoP");
201 expect(dpop.scope).toBe("orchestration:read");
202
203 const secondBearer = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
204 token,
205 ["orchestration:read"],
206 requestMetadata,
207 );
208 const firstSession = yield* serverAuth.authenticateHttpRequest(
209 makeBearerRequest(exchanged.access_token),
210 );
211 const secondSession = yield* serverAuth.authenticateHttpRequest(
212 makeBearerRequest(secondBearer.access_token),
213 );
214 expect(firstSession.subject).toBe("reusable-dev-token-child");
215 expect(secondSession.subject).toBe("reusable-dev-token-child");
216 expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token");
217 }).pipe(
218 Effect.provide(
219 makeEnvironmentAuthLayer({
220 mode: "web",
221 devUrl: new URL("http://127.0.0.1:5173"),
222 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
223 }),
224 ),
225 ),
226 );
227
228 it.effect("uses a one-time startup credential after local dev token revocation", () =>
229 Effect.gen(function* () {
230 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
231 const sessions = yield* SessionStore.SessionStore;
232 const token = "reusable-dev-auth-token-that-is-long-enough";
233 const initial = yield* serverAuth.issueStartupPairingCredential();
234 const seeded = yield* sessions.verify(token);
235
236 yield* sessions.revoke(seeded.sessionId);
237 const recovery = yield* serverAuth.issueStartupPairingCredential();
238
239 expect(initial.credential).toBe(token);
240 expect(recovery.credential).not.toBe(token);
241 expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");
242 expect(
243 (yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response,
244 ).toMatchObject({ authenticated: true, scopes: AuthAdministrativeScopes });
245 }).pipe(
246 Effect.provide(
247 makeEnvironmentAuthLayer({
248 mode: "web",
249 devUrl: new URL("http://127.0.0.1:5173"),
250 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
251 }),
252 ),
253 ),
254 );
255
256 it.effect("keeps the pairing issue error as the immediate recovery failure", () =>
257 Effect.gen(function* () {
258 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
259 const sessions = yield* SessionStore.SessionStore;
260 const sql = yield* SqlClient.SqlClient;
261 const token = "reusable-dev-auth-token-that-is-long-enough";
262 const seeded = yield* sessions.verify(token);
263
264 yield* sessions.revoke(seeded.sessionId);
265 yield* sql`
266 CREATE TRIGGER reject_startup_pairing_link
267 BEFORE INSERT ON auth_pairing_links
268 BEGIN
269 SELECT RAISE(ABORT, 'startup pairing insert rejected');
270 END
271 `;
272
273 const error = yield* Effect.flip(serverAuth.issueStartupPairingCredential());
274
275 expect(error._tag).toBe("ServerAuthPairingLinkCreationError");
276 expect(isPairingCredentialIssueError(error.cause)).toBe(true);
277 if (isPairingCredentialIssueError(error.cause)) {
278 expect(isPersistenceSqlError(error.cause.cause)).toBe(true);
279 }
280 }).pipe(
281 Effect.provide(
282 makeEnvironmentAuthLayer({
283 mode: "web",
284 devUrl: new URL("http://127.0.0.1:5173"),
285 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
286 }),
287 ),
288 ),
289 );
290
75 it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () =>291 it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () =>
76 Effect.sync(() => {292 Effect.sync(() => {
77 const error = EnvironmentAuth.toBootstrapExchangeError(293 const error = EnvironmentAuth.toBootstrapExchangeError(
apps/server/src/auth/EnvironmentAuth.ts+100-9
...@@ -31,10 +31,12 @@ import * as Schema from "effect/Schema";...@@ -31,10 +31,12 @@ import * as Schema from "effect/Schema";
31import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";31import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
3232
33import * as ServerEnvironment from "../environment/ServerEnvironment.ts";33import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
34import * as ServerConfig from "../config.ts";
34import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";35import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
35import * as PairingGrantStore from "./PairingGrantStore.ts";36import * as PairingGrantStore from "./PairingGrantStore.ts";
36import * as ServerSecretStore from "./ServerSecretStore.ts";37import * as ServerSecretStore from "./ServerSecretStore.ts";
37import * as SessionStore from "./SessionStore.ts";38import * as SessionStore from "./SessionStore.ts";
39import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts";
38import { verifyRequestDpopProof } from "./dpop.ts";40import { verifyRequestDpopProof } from "./dpop.ts";
39import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";41import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";
4042
...@@ -427,6 +429,8 @@ export class EnvironmentAuth extends Context.Service<...@@ -427,6 +429,8 @@ export class EnvironmentAuth extends Context.Service<
427 {429 {
428 readonly response: AuthBrowserSessionResult;430 readonly response: AuthBrowserSessionResult;
429 readonly sessionToken: string;431 readonly sessionToken: string;
432 readonly cookieName?: string;
433 readonly expireNormalCookie?: boolean;
430 },434 },
431 ServerAuthInvalidCredentialError | ServerAuthInternalError435 ServerAuthInvalidCredentialError | ServerAuthInternalError
432 >;436 >;
...@@ -504,6 +508,8 @@ export class EnvironmentAuth extends Context.Service<...@@ -504,6 +508,8 @@ export class EnvironmentAuth extends Context.Service<
504type BootstrapExchangeResult = {508type BootstrapExchangeResult = {
505 readonly response: AuthBrowserSessionResult;509 readonly response: AuthBrowserSessionResult;
506 readonly sessionToken: string;510 readonly sessionToken: string;
511 readonly cookieName?: string;
512 readonly expireNormalCookie?: boolean;
507};513};
508514
509const AUTHORIZATION_PREFIX = "Bearer ";515const AUTHORIZATION_PREFIX = "Bearer ";
...@@ -599,6 +605,8 @@ export const make = Effect.gen(function* () {...@@ -599,6 +605,8 @@ export const make = Effect.gen(function* () {
599 const secretStore = yield* ServerSecretStore.ServerSecretStore;605 const secretStore = yield* ServerSecretStore.ServerSecretStore;
600 const crypto = yield* Crypto.Crypto;606 const crypto = yield* Crypto.Crypto;
601 const descriptor = yield* policy.getDescriptor();607 const descriptor = yield* policy.getDescriptor();
608 const config = yield* ServerConfig.ServerConfig;
609 const devAuth = resolveReusableDevAuth(config);
602610
603 const authenticateToken = (611 const authenticateToken = (
604 token: string,612 token: string,
...@@ -630,15 +638,22 @@ export const make = Effect.gen(function* () {...@@ -630,15 +638,22 @@ export const make = Effect.gen(function* () {
630 const authenticateRequest = (638 const authenticateRequest = (
631 request: HttpServerRequest.HttpServerRequest,639 request: HttpServerRequest.HttpServerRequest,
632 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {640 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
633 const credential = selectRequestCredential(641 const selectedCredential = selectRequestCredential(
634 request,642 request,
635 sessions.cookieName,643 sessions.cookieName,
636 sessions.legacyCookieName,644 sessions.legacyCookieName,
637 );645 );
646 const dpopToken = parseDpopToken(request);
647 const hasAuthorization = request.headers.authorization !== undefined;
648 const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined;
649 const credential =
650 selectedCredential ??
651 (!hasAuthorization && devCookieToken !== undefined
652 ? { token: devCookieToken, source: "dev-cookie" as const }
653 : undefined);
638 if (!credential?.token) {654 if (!credential?.token) {
639 return Effect.fail(new ServerAuthMissingCredentialError({}));655 return Effect.fail(new ServerAuthMissingCredentialError({}));
640 }656 }
641 const dpopToken = parseDpopToken(request);
642 return authenticateToken(credential.token).pipe(657 return authenticateToken(credential.token).pipe(
643 Effect.flatMap((session) => {658 Effect.flatMap((session) => {
644 if (session.proofKeyThumbprint) {659 if (session.proofKeyThumbprint) {
...@@ -697,8 +712,32 @@ export const make = Effect.gen(function* () {...@@ -697,8 +712,32 @@ export const make = Effect.gen(function* () {
697 const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = (712 const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = (
698 credential,713 credential,
699 requestMetadata,714 requestMetadata,
700 ) =>715 ) => {
701 bootstrapCredentials.consume(credential).pipe(716 if (devAuth?.matches(credential)) {
717 return sessions.verify(credential).pipe(
718 mapSessionVerificationErrors,
719 Effect.flatMap((session) =>
720 DateTime.now.pipe(
721 Effect.map(
722 (now) =>
723 ({
724 response: {
725 authenticated: true,
726 scopes: session.scopes,
727 sessionMethod: session.method,
728 expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })),
729 } satisfies AuthBrowserSessionResult,
730 sessionToken: credential,
731 cookieName: devAuth.cookieName,
732 expireNormalCookie: true,
733 }) satisfies BootstrapExchangeResult,
734 ),
735 ),
736 ),
737 Effect.withSpan("EnvironmentAuth.createBrowserSession"),
738 );
739 }
740 return bootstrapCredentials.consume(credential).pipe(
702 Effect.mapError(toBootstrapExchangeError),741 Effect.mapError(toBootstrapExchangeError),
703 Effect.flatMap((grant) =>742 Effect.flatMap((grant) =>
704 sessions743 sessions
...@@ -729,11 +768,42 @@ export const make = Effect.gen(function* () {...@@ -729,11 +768,42 @@ export const make = Effect.gen(function* () {
729 ),768 ),
730 Effect.withSpan("EnvironmentAuth.createBrowserSession"),769 Effect.withSpan("EnvironmentAuth.createBrowserSession"),
731 );770 );
771 };
772
773 type ResolvedBootstrapGrant = Pick<
774 PairingGrantStore.BootstrapGrant,
775 "scopes" | "subject" | "label"
776 > & {
777 readonly method: PairingGrantStore.BootstrapGrant["method"] | "reusable-dev-token";
778 };
779 const resolveBootstrapGrant = (
780 credential: string,
781 input?: { readonly proofKeyThumbprint?: string },
782 ): Effect.Effect<
783 ResolvedBootstrapGrant,
784 ServerAuthInvalidCredentialError | ServerAuthInternalError
785 > => {
786 if (!devAuth?.matches(credential)) {
787 return bootstrapCredentials
788 .consume(credential, input)
789 .pipe(Effect.mapError(toBootstrapExchangeError));
790 }
791 return sessions.verify(credential).pipe(
792 mapSessionVerificationErrors,
793 Effect.map(
794 (session) =>
795 ({
796 method: "reusable-dev-token",
797 scopes: session.scopes,
798 subject: "reusable-dev-token-child",
799 }) satisfies ResolvedBootstrapGrant,
800 ),
801 );
802 };
732803
733 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =804 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =
734 (credential, requestedScopes, requestMetadata, input) =>805 (credential, requestedScopes, requestMetadata, input) =>
735 bootstrapCredentials.consume(credential, input).pipe(806 resolveBootstrapGrant(credential, input).pipe(
736 Effect.mapError(toBootstrapExchangeError),
737 Effect.flatMap((grant) =>807 Effect.flatMap((grant) =>
738 Effect.gen(function* () {808 Effect.gen(function* () {
739 const grantedScopes = requestedScopes ?? grant.scopes;809 const grantedScopes = requestedScopes ?? grant.scopes;
...@@ -917,12 +987,33 @@ export const make = Effect.gen(function* () {...@@ -917,12 +987,33 @@ export const make = Effect.gen(function* () {
917 }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential"));987 }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential"));
918988
919 const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] =989 const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] =
920 () =>990 () => {
921 issuePairingCredentialForSubject({991 const fallback = issuePairingCredentialForSubject({
922 scopes: AuthAdministrativeScopes,992 scopes: AuthAdministrativeScopes,
923 subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,993 subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
924 purpose: "startup",994 purpose: "startup",
925 }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));995 });
996 if (!devAuth) {
997 return fallback.pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
998 }
999 return sessions.verify(devAuth.credential).pipe(
1000 Effect.map(
1001 (session) =>
1002 ({
1003 id: session.sessionId,
1004 credential: devAuth.credential,
1005 label: "Reusable dev token",
1006 expiresAt: DateTime.toUtc(session.expiresAt ?? REUSABLE_DEV_SESSION_EXPIRES_AT),
1007 }) satisfies AuthPairingCredentialResult,
1008 ),
1009 Effect.catch((cause) =>
1010 SessionStore.isSessionCredentialInvalidError(cause)
1011 ? fallback
1012 : Effect.fail(new ServerAuthPairingLinkCreationError({ cause })),
1013 ),
1014 Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"),
1015 );
1016 };
9261017
927 const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) =>1018 const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) =>
928 listSessions().pipe(1019 listSessions().pipe(
apps/server/src/auth/ReusableDevAuth.ts created+31
...@@ -0,0 +1,31 @@
1import * as NodeCrypto from "node:crypto";
2import { AuthSessionId } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4import * as Redacted from "effect/Redacted";
5
6import type { ServerConfig } from "../config.ts";
7
8export const REUSABLE_DEV_SESSION_PREFIX = "dev-auth-";
9// The database schema requires an expiry for a configured token with no normal session TTL.
10export const REUSABLE_DEV_SESSION_EXPIRES_AT = DateTime.makeUnsafe("9999-12-31T23:59:59.999Z");
11
12export function resolveReusableDevAuth(
13 config: Pick<ServerConfig["Service"], "mode" | "devUrl" | "devAuthToken">,
14) {
15 if (config.mode !== "web" || config.devUrl === undefined || config.devAuthToken === undefined) {
16 return undefined;
17 }
18 const token = config.devAuthToken;
19 if (Redacted.value(token).length === 0) {
20 return undefined;
21 }
22 const hash = NodeCrypto.createHash("sha256").update(Redacted.value(token)).digest();
23 const tokenId = hash.toString("hex");
24 return {
25 credential: Redacted.value(token),
26 sessionId: AuthSessionId.make(`${REUSABLE_DEV_SESSION_PREFIX}${tokenId}`),
27 cookieName: `t3_dev_session_${tokenId}`,
28 matches: (credential: string) =>
29 NodeCrypto.timingSafeEqual(hash, NodeCrypto.createHash("sha256").update(credential).digest()),
30 };
31}
apps/server/src/auth/SessionStore.test.ts+152-1
...@@ -3,9 +3,11 @@ import { EnvironmentId } from "@t3tools/contracts";...@@ -3,9 +3,11 @@ import { EnvironmentId } from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
4import * as Duration from "effect/Duration";4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";8import * as Option from "effect/Option";
8import * as Queue from "effect/Queue";9import * as Queue from "effect/Queue";
10import * as Redacted from "effect/Redacted";
9import * as Stream from "effect/Stream";11import * as Stream from "effect/Stream";
10import * as TestClock from "effect/testing/TestClock";12import * as TestClock from "effect/testing/TestClock";
11import * as SqlClient from "effect/unstable/sql/SqlClient";13import * as SqlClient from "effect/unstable/sql/SqlClient";
...@@ -13,7 +15,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";...@@ -13,7 +15,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
13import * as ServerConfig from "../config.ts";15import * as ServerConfig from "../config.ts";
14import * as ServerEnvironment from "../environment/ServerEnvironment.ts";16import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
15import { PersistenceSqlError } from "../persistence/Errors.ts";17import { PersistenceSqlError } from "../persistence/Errors.ts";
16import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";18import {
19 makeSqlitePersistenceLive,
20 SqlitePersistenceMemory,
21} from "../persistence/Layers/Sqlite.ts";
17import * as AuthSessions from "../persistence/AuthSessions.ts";22import * as AuthSessions from "../persistence/AuthSessions.ts";
18import * as SessionStore from "./SessionStore.ts";23import * as SessionStore from "./SessionStore.ts";
19import * as ServerSecretStore from "./ServerSecretStore.ts";24import * as ServerSecretStore from "./ServerSecretStore.ts";
...@@ -54,6 +59,32 @@ const relaySessionInput = {...@@ -54,6 +59,32 @@ const relaySessionInput = {
54 client: { label: "Relay desktop", deviceType: "desktop" },59 client: { label: "Relay desktop", deviceType: "desktop" },
55} as const;60} as const;
5661
62const makeDiskSessionStoreLayer = Effect.fn("makeDiskSessionStoreLayer")(function* (
63 baseDir: string,
64 token?: string,
65) {
66 const devUrl = new URL("http://127.0.0.1:5173");
67 const paths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, {
68 baseDirIsExplicit: true,
69 });
70 yield* ServerConfig.ensureServerDirectories(paths);
71 const persistence = makeSqlitePersistenceLive(paths.dbPath);
72 return SessionStore.layer.pipe(
73 Layer.provide(persistence),
74 Layer.provide(ServerSecretStore.layer),
75 Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make(baseDir))),
76 Layer.provide(
77 makeServerConfigLayer({
78 ...paths,
79 baseDir,
80 mode: "web",
81 devUrl,
82 ...(token === undefined ? {} : { devAuthToken: Redacted.make(token) }),
83 }),
84 ),
85 );
86});
87
57const repositoryFailure = new PersistenceSqlError({88const repositoryFailure = new PersistenceSqlError({
58 operation: "AuthSessionRepository.getById:query",89 operation: "AuthSessionRepository.getById:query",
59 detail: "sqlite is unavailable",90 detail: "sqlite is unavailable",
...@@ -62,6 +93,7 @@ const repositoryFailure = new PersistenceSqlError({...@@ -62,6 +93,7 @@ const repositoryFailure = new PersistenceSqlError({
62const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, {93const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, {
63 create: () => Effect.void,94 create: () => Effect.void,
64 createReplacingActive: () => Effect.succeed([]),95 createReplacingActive: () => Effect.succeed([]),
96 createIfAbsent: () => Effect.void,
65 getById: () => Effect.fail(repositoryFailure),97 getById: () => Effect.fail(repositoryFailure),
66 listActive: () => Effect.succeed([]),98 listActive: () => Effect.succeed([]),
67 revoke: () => Effect.fail(repositoryFailure),99 revoke: () => Effect.fail(repositoryFailure),
...@@ -103,6 +135,125 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {...@@ -103,6 +135,125 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
103 }),135 }),
104 );136 );
105137
138 it.effect("keeps reusable dev auth local across disk-backed stores and restarts", () =>
139 Effect.gen(function* () {
140 const fs = yield* FileSystem.FileSystem;
141 const token = "reusable-dev-auth-token-that-is-long-enough";
142 const baseA = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-a-" });
143 const baseB = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-b-" });
144 const layerA = yield* makeDiskSessionStoreLayer(baseA, token);
145 const fromA = yield* Effect.gen(function* () {
146 const sessions = yield* SessionStore.SessionStore;
147 const dev = yield* sessions.verify(token);
148 const local = yield* sessions.issue({ subject: "environment-a" });
149 const ticket = yield* sessions.issueWebSocketToken(local.sessionId);
150 yield* sessions.revoke(dev.sessionId);
151 return { dev, local, ticket };
152 }).pipe(Effect.provide(layerA), Effect.scoped);
153
154 const layerB = yield* makeDiskSessionStoreLayer(baseB, token);
155 yield* Effect.gen(function* () {
156 const sessions = yield* SessionStore.SessionStore;
157 const dev = yield* sessions.verify(token);
158 expect(dev.sessionId).toBe(fromA.dev.sessionId);
159 expect((yield* Effect.flip(sessions.verify(fromA.local.token)))._tag).toBe(
160 "InvalidSessionTokenSignatureError",
161 );
162 expect((yield* Effect.flip(sessions.verifyWebSocketToken(fromA.ticket.token)))._tag).toBe(
163 "InvalidWebSocketTokenSignatureError",
164 );
165 }).pipe(Effect.provide(layerB), Effect.scoped);
166
167 const reopenedB = yield* makeDiskSessionStoreLayer(baseB, token);
168 yield* Effect.gen(function* () {
169 const sessions = yield* SessionStore.SessionStore;
170 const dev = yield* sessions.verify(token);
171 expect(dev.sessionId).toBe(fromA.dev.sessionId);
172 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
173 expect((yield* sessions.verifyWebSocketToken(ticket.token)).sessionId).toBe(dev.sessionId);
174 }).pipe(Effect.provide(reopenedB), Effect.scoped);
175
176 const reopenedA = yield* makeDiskSessionStoreLayer(baseA, token);
177 yield* Effect.gen(function* () {
178 const sessions = yield* SessionStore.SessionStore;
179 expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");
180 }).pipe(Effect.provide(reopenedA), Effect.scoped);
181 }),
182 );
183
184 it.effect("invalidates old dev credentials and tickets after rotation or removal", () =>
185 Effect.gen(function* () {
186 const fs = yield* FileSystem.FileSystem;
187 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-rotation-" });
188 const oldToken = "old-reusable-dev-auth-token-that-is-long-enough";
189 const newToken = "new-reusable-dev-auth-token-that-is-long-enough";
190 const initialLayer = yield* makeDiskSessionStoreLayer(baseDir, oldToken);
191 const old = yield* Effect.gen(function* () {
192 const sessions = yield* SessionStore.SessionStore;
193 const dev = yield* sessions.verify(oldToken);
194 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
195 return { dev, ticket };
196 }).pipe(Effect.provide(initialLayer), Effect.scoped);
197
198 const rotatedLayer = yield* makeDiskSessionStoreLayer(baseDir, newToken);
199 const rotated = yield* Effect.gen(function* () {
200 const sessions = yield* SessionStore.SessionStore;
201 expect((yield* Effect.flip(sessions.verify(oldToken)))._tag).toBe(
202 "MalformedSessionTokenError",
203 );
204 expect((yield* Effect.flip(sessions.verifyWebSocketToken(old.ticket.token)))._tag).toBe(
205 "UnknownWebSocketSessionError",
206 );
207 expect(
208 (yield* sessions.listActive()).some((row) => row.sessionId === old.dev.sessionId),
209 ).toBe(true);
210 const dev = yield* sessions.verify(newToken);
211 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
212 return { dev, ticket };
213 }).pipe(Effect.provide(rotatedLayer), Effect.scoped);
214
215 const removedLayer = yield* makeDiskSessionStoreLayer(baseDir);
216 yield* Effect.gen(function* () {
217 const sessions = yield* SessionStore.SessionStore;
218 expect((yield* Effect.flip(sessions.verify(newToken)))._tag).toBe(
219 "MalformedSessionTokenError",
220 );
221 expect((yield* Effect.flip(sessions.verifyWebSocketToken(rotated.ticket.token)))._tag).toBe(
222 "UnknownWebSocketSessionError",
223 );
224 expect(
225 (yield* sessions.listActive()).some((row) => row.sessionId === rotated.dev.sessionId),
226 ).toBe(true);
227 }).pipe(Effect.provide(removedLayer), Effect.scoped);
228 }),
229 );
230
231 it.effect("keeps the reusable token active after normal sessions expire", () =>
232 Effect.gen(function* () {
233 const sessions = yield* SessionStore.SessionStore;
234 const token = "reusable-dev-auth-token-that-is-long-enough";
235 const normal = yield* sessions.issue({ subject: "normal-session" });
236
237 yield* TestClock.adjust(Duration.days(31));
238
239 expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token");
240 expect((yield* Effect.flip(sessions.verify(normal.token)))._tag).toBe(
241 "SessionTokenExpiredError",
242 );
243 }).pipe(
244 Effect.provide(
245 Layer.merge(
246 makeSessionStoreLayer({
247 mode: "web",
248 devUrl: new URL("http://127.0.0.1:5173"),
249 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
250 }),
251 TestClock.layer(),
252 ),
253 ),
254 ),
255 );
256
106 it.effect("issues and verifies signed browser session tokens", () =>257 it.effect("issues and verifies signed browser session tokens", () =>
107 Effect.gen(function* () {258 Effect.gen(function* () {
108 const sessions = yield* SessionStore.SessionStore;259 const sessions = yield* SessionStore.SessionStore;
apps/server/src/auth/SessionStore.ts+70-1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 AuthSessionId,2 AuthSessionId,
3 AuthAdministrativeScopes,
3 AuthStandardClientScopes,4 AuthStandardClientScopes,
4 AuthEnvironmentScopes,5 AuthEnvironmentScopes,
5 type AuthClientMetadata,6 type AuthClientMetadata,
...@@ -24,6 +25,11 @@ import * as ServerConfig from "../config.ts";...@@ -24,6 +25,11 @@ import * as ServerConfig from "../config.ts";
24import * as ServerEnvironment from "../environment/ServerEnvironment.ts";25import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
25import * as AuthSessions from "../persistence/AuthSessions.ts";26import * as AuthSessions from "../persistence/AuthSessions.ts";
26import * as ServerSecretStore from "./ServerSecretStore.ts";27import * as ServerSecretStore from "./ServerSecretStore.ts";
28import {
29 REUSABLE_DEV_SESSION_EXPIRES_AT,
30 REUSABLE_DEV_SESSION_PREFIX,
31 resolveReusableDevAuth,
32} from "./ReusableDevAuth.ts";
27import {33import {
28 base64UrlDecodeUtf8,34 base64UrlDecodeUtf8,
29 base64UrlEncode,35 base64UrlEncode,
...@@ -416,7 +422,6 @@ export class SessionStore extends Context.Service<...@@ -416,7 +422,6 @@ export class SessionStore extends Context.Service<
416const SIGNING_SECRET_NAME = "server-signing-key";422const SIGNING_SECRET_NAME = "server-signing-key";
417const DEFAULT_SESSION_TTL = Duration.days(30);423const DEFAULT_SESSION_TTL = Duration.days(30);
418const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);424const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
419
420const SessionClaims = Schema.Struct({425const SessionClaims = Schema.Struct({
421 v: Schema.Literal(1),426 v: Schema.Literal(1),
422 kind: Schema.Literal("session"),427 kind: Schema.Literal("session"),
...@@ -492,6 +497,31 @@ export const make = Effect.gen(function* () {...@@ -492,6 +497,31 @@ export const make = Effect.gen(function* () {
492 } as const;497 } as const;
493 const cookieName = resolveSessionCookieName(cookieInput);498 const cookieName = resolveSessionCookieName(cookieInput);
494 const legacyCookieName = resolveLegacySessionCookieName(cookieInput);499 const legacyCookieName = resolveLegacySessionCookieName(cookieInput);
500 const devAuth = resolveReusableDevAuth(serverConfig);
501 if (devAuth) {
502 yield* authSessions
503 .createIfAbsent({
504 sessionId: devAuth.sessionId,
505 subject: "reusable-dev-token",
506 scopes: AuthAdministrativeScopes,
507 method: "browser-session-cookie",
508 client: {
509 label: "Reusable dev token",
510 ipAddress: null,
511 userAgent: null,
512 deviceType: "unknown",
513 os: null,
514 browser: null,
515 },
516 issuedAt: yield* DateTime.now,
517 expiresAt: REUSABLE_DEV_SESSION_EXPIRES_AT,
518 })
519 .pipe(
520 Effect.mapError(
521 (cause) => new SessionCredentialIssueError({ sessionId: devAuth.sessionId, cause }),
522 ),
523 );
524 }
495525
496 const emitUpsert = (clientSession: AuthClientSession) =>526 const emitUpsert = (clientSession: AuthClientSession) =>
497 PubSub.publish(changesPubSub, {527 PubSub.publish(changesPubSub, {
...@@ -717,6 +747,42 @@ export const make = Effect.gen(function* () {...@@ -717,6 +747,42 @@ export const make = Effect.gen(function* () {
717747
718 const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")(748 const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")(
719 function* (token) {749 function* (token) {
750 if (devAuth?.matches(token)) {
751 const row = yield* authSessions
752 .getById({ sessionId: devAuth.sessionId })
753 .pipe(
754 Effect.mapError(
755 (cause) =>
756 new SessionCredentialVerificationError({ sessionId: devAuth.sessionId, cause }),
757 ),
758 );
759 if (Option.isNone(row)) {
760 return yield* new UnknownSessionTokenError({ sessionId: devAuth.sessionId });
761 }
762 if (row.value.revokedAt !== null) {
763 return yield* new SessionTokenRevokedError({
764 sessionId: devAuth.sessionId,
765 revokedAt: row.value.revokedAt,
766 });
767 }
768 const observedAt = yield* DateTime.now;
769 if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) {
770 return yield* new SessionTokenExpiredError({
771 sessionId: devAuth.sessionId,
772 expiresAt: row.value.expiresAt,
773 observedAt,
774 });
775 }
776 return {
777 sessionId: row.value.sessionId,
778 token,
779 method: row.value.method,
780 client: toClientMetadata(row.value.client),
781 expiresAt: row.value.expiresAt,
782 subject: row.value.subject,
783 scopes: row.value.scopes,
784 } satisfies VerifiedSession;
785 }
720 const [encodedPayload, signature] = token.split(".");786 const [encodedPayload, signature] = token.split(".");
721 if (!encodedPayload || !signature) {787 if (!encodedPayload || !signature) {
722 return yield* new MalformedSessionTokenError({});788 return yield* new MalformedSessionTokenError({});
...@@ -829,6 +895,9 @@ export const make = Effect.gen(function* () {...@@ -829,6 +895,9 @@ export const make = Effect.gen(function* () {
829 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(895 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
830 Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),896 Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),
831 );897 );
898 if (claims.sid.startsWith(REUSABLE_DEV_SESSION_PREFIX) && claims.sid !== devAuth?.sessionId) {
899 return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid });
900 }
832901
833 const observedAt = yield* DateTime.now;902 const observedAt = yield* DateTime.now;
834 const expiresAt = DateTime.make(claims.exp);903 const expiresAt = DateTime.make(claims.exp);
apps/server/src/auth/http.test.ts created+137
...@@ -0,0 +1,137 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { EnvironmentHttpApi } from "@t3tools/contracts";
3import { expect, it } from "@effect/vitest";
4import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Redacted from "effect/Redacted";
10import * as Schema from "effect/Schema";
11import * as Etag from "effect/unstable/http/Etag";
12import * as HttpPlatform from "effect/unstable/http/HttpPlatform";
13import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
14import * as HttpApi from "effect/unstable/httpapi/HttpApi";
15import * as HttpRouter from "effect/unstable/http/HttpRouter";
16
17import * as ServerConfig from "../config.ts";
18import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
19import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
20import * as EnvironmentAuth from "./EnvironmentAuth.ts";
21import * as ServerSecretStore from "./ServerSecretStore.ts";
22import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./http.ts";
23
24const DEV_TOKEN = "reusable-dev-auth-token-that-is-long-enough";
25class AuthTestApi extends HttpApi.make("environment").add(EnvironmentHttpApi.groups.auth) {}
26
27const configLayer = Layer.effect(
28 ServerConfig.ServerConfig,
29 Effect.gen(function* () {
30 const config = yield* ServerConfig.ServerConfig;
31 return {
32 ...config,
33 mode: "web",
34 devUrl: new URL("http://127.0.0.1:5173"),
35 devAuthToken: Redacted.make(DEV_TOKEN),
36 } satisfies ServerConfig.ServerConfig["Service"];
37 }),
38).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-http-test-" })));
39
40const environmentAuthLayer = EnvironmentAuth.layer.pipe(
41 Layer.provide(SqlitePersistenceMemory),
42 Layer.provide(ServerSecretStore.layer),
43 Layer.provide(ServerEnvironment.identityLayer),
44 Layer.provide(configLayer),
45);
46const routesLayer = HttpApiBuilder.layer(AuthTestApi).pipe(
47 Layer.provide(authHttpApiLayer),
48 Layer.provide(environmentAuthenticatedAuthLayer),
49 Layer.provideMerge(environmentAuthLayer),
50 Layer.provide(configLayer),
51 Layer.provideMerge(
52 HttpPlatform.layer.pipe(
53 Layer.provideMerge(NodeServices.layer),
54 Layer.provideMerge(Etag.layerWeak),
55 ),
56 ),
57 Layer.provide(NodeServices.layer),
58);
59
60const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
61const postJson = (path: string, body: unknown, headers?: Readonly<Record<string, string>>) =>
62 new Request(`http://127.0.0.1${path}`, {
63 method: "POST",
64 headers: { "content-type": "application/json", ...headers },
65 body: encodeJson(body),
66 });
67
68it.effect("sets the selected browser session cookies through the HTTP route", () =>
69 Effect.gen(function* () {
70 const crypto = yield* Crypto.Crypto;
71 const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({
72 get: () => Effect.succeed(Option.none()),
73 set: () => Effect.void,
74 create: () => Effect.void,
75 getOrCreateRandom: () => Effect.die("Not used by these routes."),
76 remove: () => Effect.void,
77 });
78 const requestContext = Context.make(Crypto.Crypto, crypto).pipe(
79 Context.add(ServerSecretStore.ServerSecretStore, unusedSecretStore),
80 );
81 return yield* Effect.acquireUseRelease(
82 Effect.sync(
83 () =>
84 [
85 HttpRouter.toWebHandler(routesLayer, { disableLogger: true }),
86 HttpRouter.toWebHandler(routesLayer, { disableLogger: true }),
87 ] as const,
88 ),
89 ([environmentA, environmentB]) =>
90 Effect.tryPromise(async () => {
91 const devResponse = await environmentA.handler(
92 postJson("/api/auth/browser-session", { credential: DEV_TOKEN }),
93 requestContext,
94 );
95 expect(devResponse.status).toBe(200);
96 const devCookies = devResponse.headers.getSetCookie();
97 const devCookie = devCookies.find((cookie) => cookie.startsWith("t3_dev_session_"));
98 expect(devCookie).toContain("HttpOnly");
99 expect(devCookie).toContain(`=${DEV_TOKEN};`);
100 expect(devCookies).toContainEqual(
101 expect.stringMatching(/^t3_session_[^=]*=;.*Max-Age=0/),
102 );
103 const devCookieHeader = devCookie?.split(";", 1)[0] ?? "";
104 const environmentBSession = await environmentB.handler(
105 new Request("http://127.0.0.1/api/auth/session", {
106 headers: { cookie: devCookieHeader },
107 }),
108 requestContext,
109 );
110 expect(environmentBSession.status).toBe(200);
111 expect(await environmentBSession.json()).toMatchObject({ authenticated: true });
112
113 const pairingResponse = await environmentA.handler(
114 postJson(
115 "/api/auth/pairing-token",
116 { scopes: ["orchestration:read"] },
117 { cookie: devCookieHeader },
118 ),
119 requestContext,
120 );
121 expect(pairingResponse.status).toBe(200);
122 const pairing = (await pairingResponse.json()) as { credential: string };
123 const restrictedResponse = await environmentA.handler(
124 postJson("/api/auth/browser-session", { credential: pairing.credential }),
125 requestContext,
126 );
127 expect(restrictedResponse.status).toBe(200);
128 const restrictedCookies = restrictedResponse.headers.getSetCookie();
129 expect(restrictedCookies).toHaveLength(1);
130 expect(restrictedCookies[0]).toMatch(/^t3_session_/);
131 expect(restrictedCookies[0]).not.toContain("t3_dev_session_");
132 }),
133 ([environmentA, environmentB]) =>
134 Effect.promise(() => Promise.all([environmentA.dispose(), environmentB.dispose()])),
135 );
136 }).pipe(Effect.provide(NodeServices.layer)),
137);
apps/server/src/auth/http.ts+21-4
...@@ -273,10 +273,27 @@ export const authHttpApiLayer = HttpApiBuilder.group(...@@ -273,10 +273,27 @@ export const authHttpApiLayer = HttpApiBuilder.group(
273 args.payload.credential,273 args.payload.credential,
274 deriveAuthClientMetadata({ request }),274 deriveAuthClientMetadata({ request }),
275 );275 );
276 yield* appendSessionCookie(276 const cookieName = result.cookieName ?? sessions.cookieName;
277 sessions.cookieName,277 const selectedCookie = yield* Effect.fromResult(
278 result.sessionToken,278 Cookies.set(Cookies.empty, cookieName, result.sessionToken, {
279 result.response.expiresAt,279 expires: DateTime.toDate(result.response.expiresAt),
280 httpOnly: true,
281 path: "/",
282 sameSite: "lax",
283 }),
284 ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")));
285 const sessionCookies = result.expireNormalCookie
286 ? yield* Effect.fromResult(
287 Cookies.expireCookie(selectedCookie, sessions.cookieName, {
288 httpOnly: true,
289 path: "/",
290 sameSite: "lax",
291 }),
292 ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")))
293 : selectedCookie;
294
295 yield* HttpEffect.appendPreResponseHandler((_request, response) =>
296 Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)),
280 );297 );
281 yield* appendCredentialResponseHeaders;298 yield* appendCredentialResponseHeaders;
282 return result.response;299 return result.response;
apps/server/src/cli/config.test.ts+89
...@@ -9,6 +9,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -9,6 +9,7 @@ import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";10import * as Option from "effect/Option";
11import * as Path from "effect/Path";11import * as Path from "effect/Path";
12import * as Redacted from "effect/Redacted";
12import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
1314
14import {15import {
...@@ -24,6 +25,7 @@ const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>...@@ -24,6 +25,7 @@ const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>
24 deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true });25 deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true });
2526
26const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));27const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));
28const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
2729
28const makeDesktopBootstrap = (30const makeDesktopBootstrap = (
29 overrides: Partial<DesktopBackendBootstrapValue> = {},31 overrides: Partial<DesktopBackendBootstrapValue> = {},
...@@ -73,6 +75,93 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {...@@ -73,6 +75,93 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
73 );75 );
74 });76 });
7577
78 it.effect("enables a trimmed reusable auth token only for web dev mode", () =>
79 Effect.gen(function* () {
80 const baseDir = yield* FileSystem.FileSystem.pipe(
81 Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-" })),
82 );
83 const flags = {
84 mode: Option.some("web" as const),
85 port: Option.some(8788),
86 host: Option.none<string>(),
87 baseDir: Option.some(baseDir),
88 cwd: Option.none<string>(),
89 devUrl: Option.some(new URL("http://127.0.0.1:5173")),
90 noBrowser: Option.none<boolean>(),
91 bootstrapFd: Option.none<number>(),
92 autoBootstrapProjectFromCwd: Option.none<boolean>(),
93 logWebSocketEvents: Option.none<boolean>(),
94 tailscaleServeEnabled: Option.none<boolean>(),
95 tailscaleServePort: Option.none<number>(),
96 };
97 const configLayer = ConfigProvider.layer(
98 ConfigProvider.fromEnv({
99 env: {
100 T3CODE_DEV_AUTH_TOKEN: " reusable-dev-auth-token-that-is-long-enough ",
101 },
102 }),
103 );
104 const web = yield* resolveServerConfig(flags, Option.none()).pipe(
105 Effect.provide(Layer.mergeAll(configLayer, NetService.layer)),
106 );
107 const desktop = yield* resolveServerConfig(
108 { ...flags, mode: Option.some("desktop" as const) },
109 Option.none(),
110 ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer)));
111
112 expect(web.devAuthToken).toBeDefined();
113 if (web.devAuthToken === undefined) {
114 return yield* Effect.die("Expected reusable dev auth token.");
115 }
116 expect(Redacted.value(web.devAuthToken)).toBe("reusable-dev-auth-token-that-is-long-enough");
117 expect(desktop.devAuthToken).toBeUndefined();
118 }),
119 );
120
121 it.effect("does not expose an invalid reusable auth token", () =>
122 Effect.gen(function* () {
123 const secret = "short-secret";
124 const baseDir = yield* FileSystem.FileSystem.pipe(
125 Effect.flatMap((fs) => fs.makeTempDirectoryScoped({ prefix: "t3-cli-dev-auth-invalid-" })),
126 );
127 const flags = {
128 mode: Option.some("web" as const),
129 port: Option.some(8788),
130 host: Option.none<string>(),
131 baseDir: Option.some(baseDir),
132 cwd: Option.none<string>(),
133 devUrl: Option.some(new URL("http://127.0.0.1:5173")),
134 noBrowser: Option.none<boolean>(),
135 bootstrapFd: Option.none<number>(),
136 autoBootstrapProjectFromCwd: Option.none<boolean>(),
137 logWebSocketEvents: Option.none<boolean>(),
138 tailscaleServeEnabled: Option.none<boolean>(),
139 tailscaleServePort: Option.none<number>(),
140 };
141 const configLayer = ConfigProvider.layer(
142 ConfigProvider.fromEnv({ env: { T3CODE_DEV_AUTH_TOKEN: secret } }),
143 );
144 const error = yield* resolveServerConfig(flags, Option.none()).pipe(
145 Effect.provide(Layer.mergeAll(configLayer, NetService.layer)),
146 Effect.flip,
147 );
148 const desktop = yield* resolveServerConfig(
149 { ...flags, mode: Option.some("desktop" as const) },
150 Option.none(),
151 ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer)));
152 const staticWeb = yield* resolveServerConfig(
153 { ...flags, devUrl: Option.none() },
154 Option.none(),
155 ).pipe(Effect.provide(Layer.mergeAll(configLayer, NetService.layer)));
156
157 expect(String(error)).not.toContain(secret);
158 const serialized = yield* encodeUnknownJson(error);
159 expect(serialized).not.toContain(secret);
160 expect(desktop.devAuthToken).toBeUndefined();
161 expect(staticWeb.devAuthToken).toBeUndefined();
162 }),
163 );
164
76 it.effect("falls back to effect/config values when flags are omitted", () =>165 it.effect("falls back to effect/config values when flags are omitted", () =>
77 Effect.gen(function* () {166 Effect.gen(function* () {
78 const { join } = yield* Path.Path;167 const { join } = yield* Path.Path;
apps/server/src/cli/config.ts+24
...@@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem";
8import * as LogLevel from "effect/LogLevel";8import * as LogLevel from "effect/LogLevel";
9import * as Option from "effect/Option";9import * as Option from "effect/Option";
10import * as Path from "effect/Path";10import * as Path from "effect/Path";
11import * as Redacted from "effect/Redacted";
11import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
12import * as SchemaIssue from "effect/SchemaIssue";13import * as SchemaIssue from "effect/SchemaIssue";
13import * as SchemaTransformation from "effect/SchemaTransformation";14import * as SchemaTransformation from "effect/SchemaTransformation";
...@@ -141,6 +142,26 @@ const EnvServerConfig = Config.all({...@@ -141,6 +142,26 @@ const EnvServerConfig = Config.all({
141 ),142 ),
142});143});
143144
145const DevAuthTokenConfig = Config.redacted("T3CODE_DEV_AUTH_TOKEN").pipe(
146 Config.map((token) => Redacted.make(Redacted.value(token).trim())),
147 Config.mapOrFail((token) =>
148 Redacted.value(token).length === 0 || Redacted.value(token).length >= 32
149 ? Effect.succeed(token)
150 : Effect.fail(
151 new Config.ConfigError(
152 new Schema.SchemaError(
153 new SchemaIssue.InvalidValue({
154 message: "T3CODE_DEV_AUTH_TOKEN must contain at least 32 characters.",
155 }),
156 ),
157 ),
158 ),
159 ),
160 Config.option,
161 Config.map(Option.filter((token) => Redacted.value(token).length > 0)),
162 Config.map(Option.getOrUndefined),
163);
164
144export interface CliServerFlags {165export interface CliServerFlags {
145 readonly mode: Option.Option<ServerConfig.RuntimeMode>;166 readonly mode: Option.Option<ServerConfig.RuntimeMode>;
146 readonly port: Option.Option<number>;167 readonly port: Option.Option<number>;
...@@ -268,6 +289,8 @@ export const resolveServerConfig = (...@@ -268,6 +289,8 @@ export const resolveServerConfig = (
268 resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)),289 resolveOptionPrecedence(normalizedFlags.devUrl, Option.fromUndefinedOr(env.devUrl)),
269 () => undefined,290 () => undefined,
270 );291 );
292 const devAuthToken =
293 mode === "web" && devUrl !== undefined ? yield* DevAuthTokenConfig : undefined;
271 const explicitBaseDir = resolveOptionPrecedence(294 const explicitBaseDir = resolveOptionPrecedence(
272 normalizedFlags.baseDir,295 normalizedFlags.baseDir,
273 Option.fromUndefinedOr(env.t3Home),296 Option.fromUndefinedOr(env.t3Home),
...@@ -373,6 +396,7 @@ export const resolveServerConfig = (...@@ -373,6 +396,7 @@ export const resolveServerConfig = (
373 host,396 host,
374 staticDir,397 staticDir,
375 devUrl,398 devUrl,
399 ...(devAuthToken === undefined ? {} : { devAuthToken }),
376 devAllowedOrigins: env.devAllowedOrigins,400 devAllowedOrigins: env.devAllowedOrigins,
377 noBrowser,401 noBrowser,
378 startupPresentation,402 startupPresentation,
apps/server/src/config.ts+2
...@@ -13,6 +13,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -13,6 +13,7 @@ import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
14import * as LogLevel from "effect/LogLevel";14import * as LogLevel from "effect/LogLevel";
15import * as Path from "effect/Path";15import * as Path from "effect/Path";
16import type * as Redacted from "effect/Redacted";
16import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
1718
18import { sweepStalePendingAttachments } from "./attachmentStore.ts";19import { sweepStalePendingAttachments } from "./attachmentStore.ts";
...@@ -79,6 +80,7 @@ export class ServerConfig extends Context.Service<...@@ -79,6 +80,7 @@ export class ServerConfig extends Context.Service<
79 readonly baseDir: string;80 readonly baseDir: string;
80 readonly staticDir: string | undefined;81 readonly staticDir: string | undefined;
81 readonly devUrl: URL | undefined;82 readonly devUrl: URL | undefined;
83 readonly devAuthToken?: Redacted.Redacted<string> | undefined;
82 readonly devAllowedOrigins: ReadonlyArray<string>;84 readonly devAllowedOrigins: ReadonlyArray<string>;
83 readonly noBrowser: boolean;85 readonly noBrowser: boolean;
84 readonly startupPresentation: StartupPresentation;86 readonly startupPresentation: StartupPresentation;
apps/server/src/persistence/AuthSessions.ts+24-5
...@@ -107,6 +107,9 @@ export class AuthSessionRepository extends Context.Service<...@@ -107,6 +107,9 @@ export class AuthSessionRepository extends Context.Service<
107 readonly createReplacingActive: (107 readonly createReplacingActive: (
108 input: CreateReplacingActiveAuthSessionInput,108 input: CreateReplacingActiveAuthSessionInput,
109 ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>;109 ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>;
110 readonly createIfAbsent: (
111 input: CreateAuthSessionInput,
112 ) => Effect.Effect<void, AuthSessionRepositoryError>;
110 readonly getById: (113 readonly getById: (
111 input: GetAuthSessionByIdInput,114 input: GetAuthSessionByIdInput,
112 ) => Effect.Effect<Option.Option<AuthSessionRecord>, AuthSessionRepositoryError>;115 ) => Effect.Effect<Option.Option<AuthSessionRecord>, AuthSessionRepositoryError>;
...@@ -204,10 +207,11 @@ function toPersistenceSqlOrDecodeError(...@@ -204,10 +207,11 @@ function toPersistenceSqlOrDecodeError(
204export const make = Effect.gen(function* () {207export const make = Effect.gen(function* () {
205 const sql = yield* SqlClient.SqlClient;208 const sql = yield* SqlClient.SqlClient;
206209
207 const createSessionRow = SqlSchema.void({210 const insertSessionRow = (ignoreExisting: boolean) =>
208 Request: CreateAuthSessionInput,211 SqlSchema.void({
209 execute: (input) =>212 Request: CreateAuthSessionInput,
210 sql`213 execute: (input) =>
214 sql`
211 INSERT INTO auth_sessions (215 INSERT INTO auth_sessions (
212 session_id,216 session_id,
213 subject,217 subject,
...@@ -238,8 +242,11 @@ export const make = Effect.gen(function* () {...@@ -238,8 +242,11 @@ export const make = Effect.gen(function* () {
238 ${input.expiresAt},242 ${input.expiresAt},
239 NULL243 NULL
240 )244 )
245 ${ignoreExisting ? sql`ON CONFLICT(session_id) DO NOTHING` : sql``}
241 `,246 `,
242 });247 });
248 const createSessionRow = insertSessionRow(false);
249 const createSessionRowIfAbsent = insertSessionRow(true);
243250
244 const getSessionRowById = SqlSchema.findOneOption({251 const getSessionRowById = SqlSchema.findOneOption({
245 Request: GetAuthSessionByIdInput,252 Request: GetAuthSessionByIdInput,
...@@ -393,6 +400,17 @@ export const make = Effect.gen(function* () {...@@ -393,6 +400,17 @@ export const make = Effect.gen(function* () {
393 ),400 ),
394 );401 );
395402
403 const createIfAbsent: AuthSessionRepository["Service"]["createIfAbsent"] = (input) =>
404 createSessionRowIfAbsent(input).pipe(
405 Effect.mapError(
406 toPersistenceSqlOrDecodeError(
407 "AuthSessionRepository.createIfAbsent:query",
408 "AuthSessionRepository.createIfAbsent:encodeRequest",
409 { sessionId: input.sessionId },
410 ),
411 ),
412 );
413
396 const getById: AuthSessionRepository["Service"]["getById"] = (input) =>414 const getById: AuthSessionRepository["Service"]["getById"] = (input) =>
397 getSessionRowById(input).pipe(415 getSessionRowById(input).pipe(
398 Effect.mapError(416 Effect.mapError(
...@@ -493,6 +511,7 @@ export const make = Effect.gen(function* () {...@@ -493,6 +511,7 @@ export const make = Effect.gen(function* () {
493 return {511 return {
494 create,512 create,
495 createReplacingActive,513 createReplacingActive,
514 createIfAbsent,
496 getById,515 getById,
497 listActive,516 listActive,
498 revoke,517 revoke,
apps/web/src/authBootstrap.test.ts+139
...@@ -506,6 +506,145 @@ describe("resolveInitialServerAuthGateState", () => {...@@ -506,6 +506,145 @@ describe("resolveInitialServerAuthGateState", () => {
506 expect(testApi.calls.session).toBe(1);506 expect(testApi.calls.session).toBe(1);
507 });507 });
508508
509 it("exchanges a URL token when the browser already has a session", async () => {
510 const testApi = await installAuthApi({
511 session: () => authenticatedSession(LOOPBACK_AUTH),
512 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
513 });
514 const testWindow = installTestBrowser("http://localhost/#token=reusable-token");
515 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
516
517 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
518 status: "authenticated",
519 });
520
521 expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]);
522 expect(testWindow.location.hash).toBe("");
523 });
524
525 it("exchanges an explicit pair link after caching an authenticated state", async () => {
526 const testApi = await installAuthApi({
527 session: () => authenticatedSession(LOOPBACK_AUTH),
528 browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
529 });
530 const testWindow = installTestBrowser("http://localhost/");
531 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
532
533 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
534 status: "authenticated",
535 });
536 testWindow.location = new URL("http://localhost/pair#token=reusable-token");
537
538 await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]);
539
540 expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]);
541 expect(testApi.calls.session).toBe(3);
542 });
543
544 it("makes later callers wait for a URL token that arrives during bootstrap", async () => {
545 let releaseExchange!: () => void;
546 const exchangeRelease = new Promise<void>((resolve) => {
547 releaseExchange = resolve;
548 });
549 let markExchangeStarted!: () => void;
550 const exchangeStarted = new Promise<void>((resolve) => {
551 markExchangeStarted = resolve;
552 });
553 const nextSession = sequence(
554 authenticatedSession(LOOPBACK_AUTH),
555 authenticatedSession(LOOPBACK_AUTH),
556 );
557 const testApi = await installAuthApi({
558 session: nextSession,
559 browserSession: () => {
560 markExchangeStarted();
561 return Effect.promise(() => exchangeRelease).pipe(
562 Effect.andThen(
563 Effect.fail(
564 new EnvironmentAuthInvalidError({
565 code: "auth_invalid",
566 reason: "invalid_credential",
567 traceId: "trace-rejected-queued-credential",
568 }),
569 ),
570 ),
571 );
572 },
573 });
574 const testWindow = installTestBrowser("http://localhost/");
575 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
576
577 const initialBootstrap = resolveInitialServerAuthGateState();
578 testWindow.location = new URL("http://localhost/pair#token=reusable-token");
579 const explicitPairing = resolveInitialServerAuthGateState();
580 const laterCaller = resolveInitialServerAuthGateState();
581 let laterCallerSettled = false;
582 void laterCaller.then(() => {
583 laterCallerSettled = true;
584 });
585
586 try {
587 await expect(initialBootstrap).resolves.toEqual({ status: "authenticated" });
588 await exchangeStarted;
589 expect(laterCallerSettled).toBe(false);
590 } finally {
591 releaseExchange();
592 }
593
594 const rejectedState = {
595 status: "requires-auth",
596 auth: LOOPBACK_AUTH,
597 errorMessage: "Invalid pairing token. Check the token and try again.",
598 } as const;
599 await expect(explicitPairing).resolves.toEqual(rejectedState);
600 await expect(laterCaller).resolves.toEqual(rejectedState);
601 expect(testApi.calls.browserSession).toEqual([{ credential: "reusable-token" }]);
602 expect(testApi.calls.session).toBe(2);
603 });
604
605 it("does not exchange a token during an ordinary authenticated load", async () => {
606 const testApi = await installAuthApi({
607 session: () => authenticatedSession(LOOPBACK_AUTH),
608 browserSession: () => Effect.succeed(browserSession(["orchestration:read"])),
609 });
610 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
611
612 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
613 status: "authenticated",
614 });
615
616 expect(testApi.calls.browserSession).toEqual([]);
617 });
618
619 it("reports a rejected URL token without caching false success", async () => {
620 const cause = new EnvironmentAuthInvalidError({
621 code: "auth_invalid",
622 reason: "invalid_credential",
623 traceId: "trace-invalid-url-credential",
624 });
625 const nextSession = sequence(
626 authenticatedSession(LOOPBACK_AUTH),
627 unauthenticatedSession(LOOPBACK_AUTH),
628 );
629 const testApi = await installAuthApi({
630 session: nextSession,
631 browserSession: () => Effect.fail(cause),
632 });
633 installTestBrowser("http://localhost/#token=rejected-token");
634 const { resolveInitialServerAuthGateState } = await import("./environments/primary");
635
636 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
637 status: "requires-auth",
638 auth: LOOPBACK_AUTH,
639 errorMessage: "Invalid pairing token. Check the token and try again.",
640 });
641 await expect(resolveInitialServerAuthGateState()).resolves.toEqual({
642 status: "requires-auth",
643 auth: LOOPBACK_AUTH,
644 });
645 expect(testApi.calls.browserSession).toEqual([{ credential: "rejected-token" }]);
646 });
647
509 it("creates a pairing credential from the authenticated auth endpoint", async () => {648 it("creates a pairing credential from the authenticated auth endpoint", async () => {
510 const testApi = await installAuthApi({649 const testApi = await installAuthApi({
511 pairingCredential: (payload) =>650 pairingCredential: (payload) =>
apps/web/src/environments/primary/auth.ts+23-10
...@@ -307,13 +307,13 @@ function isTransientBootstrapError(error: unknown): boolean {...@@ -307,13 +307,13 @@ function isTransientBootstrapError(error: unknown): boolean {
307 return error instanceof DOMException && error.name === "AbortError";307 return error instanceof DOMException && error.name === "AbortError";
308}308}
309309
310async function bootstrapServerAuth(): Promise<ServerAuthGateState> {310async function bootstrapServerAuth(urlCredential: string | null): Promise<ServerAuthGateState> {
311 const bootstrapCredential = getDesktopBootstrapCredential();
312 const currentSession = await fetchSessionState();311 const currentSession = await fetchSessionState();
313 if (currentSession.authenticated) {312 if (currentSession.authenticated && !urlCredential) {
314 return { status: "authenticated" };313 return { status: "authenticated" };
315 }314 }
316315
316 const bootstrapCredential = urlCredential ?? getDesktopBootstrapCredential();
317 if (!bootstrapCredential) {317 if (!bootstrapCredential) {
318 return {318 return {
319 status: "requires-auth",319 status: "requires-auth",
...@@ -428,19 +428,32 @@ export async function revokeOtherServerClientSessions(): Promise<number> {...@@ -428,19 +428,32 @@ export async function revokeOtherServerClientSessions(): Promise<number> {
428}428}
429429
430export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {430export async function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {
431 if (resolvedAuthenticatedGateState?.status === "authenticated") {431 const urlCredential = takePairingTokenFromUrl();
432 return resolvedAuthenticatedGateState;432 const previousPromise = bootstrapPromise;
433 }433 if (urlCredential) {
434 resolvedAuthenticatedGateState = null;
435 } else {
436 if (previousPromise) {
437 return previousPromise;
438 }
434439
435 if (bootstrapPromise) {440 if (resolvedAuthenticatedGateState?.status === "authenticated") {
436 return bootstrapPromise;441 return resolvedAuthenticatedGateState;
442 }
437 }443 }
438444
439 const nextPromise = bootstrapServerAuth();445 const nextPromise = previousPromise
446 ? previousPromise
447 .catch(() => undefined)
448 .then(() => {
449 resolvedAuthenticatedGateState = null;
450 return bootstrapServerAuth(urlCredential);
451 })
452 : bootstrapServerAuth(urlCredential);
440 bootstrapPromise = nextPromise;453 bootstrapPromise = nextPromise;
441 return nextPromise454 return nextPromise
442 .then((result) => {455 .then((result) => {
443 if (result.status === "authenticated") {456 if (bootstrapPromise === nextPromise && result.status === "authenticated") {
444 resolvedAuthenticatedGateState = result;457 resolvedAuthenticatedGateState = result;
445 }458 }
446 return result;459 return result;
docs/internals/environment-auth.md+19
...@@ -37,6 +37,25 @@ transaction](../../apps/server/src/persistence/AuthSessions.ts); a failed...@@ -37,6 +37,25 @@ transaction](../../apps/server/src/persistence/AuthSessions.ts); a failed
37replacement must leave the old credential usable. Pairing and browser sessions37replacement must leave the old credential usable. Pairing and browser sessions
38do not follow this replacement rule.38do not follow this replacement rule.
3939
40### Reusable dev credential
41
42Web development environments can accept one `T3CODE_DEV_AUTH_TOKEN` across
43worktrees and ports on one hostname. The token and startup URLs that contain it
44grant administrative access. Desktop and non-development servers ignore it. See
45the [development runbook](../operations/development.md#reusable-dev-credential)
46for setup.
47
48Each environment hashes the value and seeds its own database record at startup.
49Environments do not share SQLite data, signing keys, environment IDs, session
50records, pairing grants, or revocation state. Local revocation persists after
51restart and does not affect another worktree. Removing or rotating the value
52and restarting invalidates the old credential and its WebSocket tickets.
53
54Normal credentials keep precedence. A rejected normal credential never falls
55back to the reusable credential. OAuth exchanges create ordinary local bearer
56or DPoP children with normal expiry and revocation. The reusable cookie expires
57after 30 days.
58
40## The environment is the filesystem boundary59## The environment is the filesystem boundary
4160
42Projects are organizational boundaries, not filesystem sandboxes.61Projects are organizational boundaries, not filesystem sandboxes.
docs/operations/development.md+42-1
...@@ -10,7 +10,7 @@ vp i...@@ -10,7 +10,7 @@ vp i
10vp run dev10vp run dev
11```11```
1212
13Open the one-time pairing URL printed by the dev runner. The bare origin does not authenticate13Open the pairing URL printed by the dev runner. The bare origin does not authenticate
14a new browser.14a new browser.
1515
16Prefer a container? See [Dev container](../internals/devcontainer.md) for VS Code and Codespaces setup.16Prefer a container? See [Dev container](../internals/devcontainer.md) for VS Code and Codespaces setup.
...@@ -56,6 +56,47 @@ when changing this setup:...@@ -56,6 +56,47 @@ when changing this setup:
56The workarounds live in the [web entry](../../apps/web/src/bootstrap.ts) and56The workarounds live in the [web entry](../../apps/web/src/bootstrap.ts) and
57[Tailwind plugin](../../apps/web/vite/tailwind.ts).57[Tailwind plugin](../../apps/web/vite/tailwind.ts).
5858
59#### Reusable dev credential
60
61Use this only on a hostname where you trust every service. Browsers send cookies to all ports
62on that hostname. Any service you visit there can receive the reusable admin credential,
63including services unrelated to T3 Code. If you run untrusted services on that hostname, keep
64normal per-environment pairing instead.
65
66To use one browser profile across web dev worktrees on the same hostname, generate one fixed
67value once:
68
69```sh
70openssl rand -hex 32
71```
72
73Put that value in the main checkout's gitignored `.env`:
74
75```dotenv
76T3CODE_DEV_AUTH_TOKEN=<the value generated above>
77```
78
79The `t3.json` Setup Worktree commands on Unix and Windows link that file to each worktree's
80`.env`. The dev runner reads repository env files at startup. `.env.local` and inherited process
81environment values override `.env`, so no per-worktree export is needed after setup.
82
83For a manual worktree or launcher without that link, export the same fixed value instead:
84
85```sh
86export T3CODE_DEV_AUTH_TOKEN="<the value generated above>"
87```
88
89Do not generate a new value at startup. Start or restart `vp run dev --share` after configuration,
90then open its printed startup pairing URL once per browser profile on that hostname. Later web dev
91servers on the same hostname accept the shared cookie across ports. The cookie expires after 30
92days. Reload an old tab if its URL now serves a replacement environment.
93
94The token and startup pairing URLs are reusable administrative secrets. Never put them in a
95commit, pull request, or public output. Every server still seeds its own auth database record at
96startup and keeps its own SQLite data, signing key, and revocation state. Desktop and non-dev
97servers ignore the value. See [environment authentication](../internals/environment-auth.md#reusable-dev-credential)
98for the security model.
99
59## Checks100## Checks
60101
61Run checks for the files and packages you changed:102Run checks for the files and packages you changed:
packages/client-runtime/src/rpc/session.test.ts+31
...@@ -1057,6 +1057,37 @@ describe("RpcSessionFactory", () => {...@@ -1057,6 +1057,37 @@ describe("RpcSessionFactory", () => {
1057 ),1057 ),
1058 );1058 );
10591059
1060 it.effect("rejects a server config for a different environment", () =>
1061 Effect.gen(function* () {
1062 const { factory, sockets } = yield* makeFactory();
1063 const session = yield* factory.connect(PREPARED);
1064 const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready));
1065 const configFiber = yield* session
1066 .subscribeServerConfig({})
1067 .pipe(Stream.runHead, Effect.flip, Effect.forkChild);
1068 const customConfigFiber = yield* session
1069 .subscribeServerConfig({ environmentThemes: true })
1070 .pipe(Stream.runHead, Effect.flip, Effect.forkChild);
1071 const socket = yield* awaitSocket(sockets);
1072 socket.open();
1073 yield* completeInitialConfig(socket, {
1074 ...ENCODED_SERVER_CONFIG,
1075 environment: {
1076 ...ENCODED_SERVER_CONFIG.environment,
1077 environmentId: "environment-2",
1078 },
1079 });
1080
1081 const error = yield* Fiber.join(readyFiber);
1082 expect(error).toMatchObject({
1083 reason: "configuration",
1084 message: "Connected environment environment-2 does not match environment-1.",
1085 });
1086 expect((yield* Fiber.join(configFiber))._tag).toBe("RpcClientError");
1087 expect((yield* Fiber.join(customConfigFiber))._tag).toBe("RpcClientError");
1088 }),
1089 );
1090
1060 it.effect("tolerates two missed pong windows before closing the session", () =>1091 it.effect("tolerates two missed pong windows before closing the session", () =>
1061 Effect.gen(function* () {1092 Effect.gen(function* () {
1062 const { factory, sockets } = yield* makeFactory();1093 const { factory, sockets } = yield* makeFactory();
packages/client-runtime/src/rpc/session.ts+32-8
...@@ -40,6 +40,7 @@ import {...@@ -40,6 +40,7 @@ import {
40 type ServerConfigProjection,40 type ServerConfigProjection,
41 withoutEnvironmentThemes,41 withoutEnvironmentThemes,
42} from "../state/serverConfigProjection.ts";42} from "../state/serverConfigProjection.ts";
43import { environmentMismatchError } from "../connection/errors.ts";
4344
44const SOCKET_OPEN_TIMEOUT = "15 seconds";45const SOCKET_OPEN_TIMEOUT = "15 seconds";
4546
...@@ -289,14 +290,20 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (...@@ -289,14 +290,20 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
289 Effect.mapError(mapRpcError),290 Effect.mapError(mapRpcError),
290 Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)),291 Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)),
291 ),292 ),
292 ).pipe(Effect.withSpan("environment.initialSync"));293 ).pipe(
294 Effect.flatMap((config) =>
295 config.environment.environmentId === connection.environmentId
296 ? Effect.succeed(config)
297 : environmentMismatchError({
298 expected: connection.environmentId,
299 actual: config.environment.environmentId,
300 }),
301 ),
302 Effect.withSpan("environment.initialSync"),
303 );
293 const serverConfigEvents = Stream.unwrap(304 const serverConfigEvents = Stream.unwrap(
294 Effect.gen(function* () {305 Effect.gen(function* () {
295 const subscription = yield* PubSub.subscribe(serverConfigUpdates);306 const subscription = yield* PubSub.subscribe(serverConfigUpdates);
296 yield* Effect.raceFirst(
297 Deferred.await(initialConfigDeferred).pipe(Effect.asVoid),
298 Deferred.await(serverConfigExit),
299 );
300 const snapshot = yield* Ref.get(serverConfigState);307 const snapshot = yield* Ref.get(serverConfigState);
301 if (Option.isNone(snapshot)) {308 if (Option.isNone(snapshot)) {
302 return Stream.empty;309 return Stream.empty;
...@@ -336,10 +343,27 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (...@@ -336,10 +343,27 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
336 );343 );
337 }),344 }),
338 );345 );
346 const validatedInitialConfig = initialConfig.pipe(
347 Effect.mapError(
348 (cause) =>
349 new RpcClientError.RpcClientError({
350 reason: new RpcClientError.RpcClientDefect({
351 message: `${connection.label} config subscription failed.`,
352 cause,
353 }),
354 }),
355 ),
356 );
339 const subscribeServerConfig = (input: ServerConfigSubscriptionInput) =>357 const subscribeServerConfig = (input: ServerConfigSubscriptionInput) =>
340 Equal.equals(input, serverConfigInput)358 Stream.unwrap(
341 ? serverConfigEvents359 validatedInitialConfig.pipe(
342 : protocolClient[WS_METHODS.subscribeServerConfig](input);360 Effect.as(
361 Equal.equals(input, serverConfigInput)
362 ? serverConfigEvents
363 : protocolClient[WS_METHODS.subscribeServerConfig](input),
364 ),
365 ),
366 );
343 const probe = initialConfig.pipe(367 const probe = initialConfig.pipe(
344 Effect.flatMap((config) =>368 Effect.flatMap((config) =>
345 (config.environment.capabilities.connectionProbe === true369 (config.environment.capabilities.connectionProbe === true
scripts/dev-runner.test.ts+21
...@@ -152,6 +152,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -152,6 +152,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
152 });152 });
153153
154 describe("createDevRunnerEnv", () => {154 describe("createDevRunnerEnv", () => {
155 it.effect("forwards the reusable auth token to web dev and removes it for desktop", () =>
156 Effect.gen(function* () {
157 const input = {
158 baseEnv: { T3CODE_DEV_AUTH_TOKEN: "reusable-dev-auth-token-that-is-long-enough" },
159 serverOffset: 0,
160 webOffset: 0,
161 t3Home: undefined,
162 browser: undefined,
163 autoBootstrapProjectFromCwd: undefined,
164 logWebSocketEvents: undefined,
165 host: undefined,
166 port: undefined,
167 devUrl: undefined,
168 } as const;
169 const web = yield* createDevRunnerEnv({ ...input, mode: "dev" });
170 const desktop = yield* createDevRunnerEnv({ ...input, mode: "dev:desktop" });
171
172 assert.equal(web.T3CODE_DEV_AUTH_TOKEN, input.baseEnv.T3CODE_DEV_AUTH_TOKEN);
173 assert.equal(desktop.T3CODE_DEV_AUTH_TOKEN, undefined);
174 }),
175 );
155 it.effect("leaves the shared home implicit and disables browser auto-open", () =>176 it.effect("leaves the shared home implicit and disables browser auto-open", () =>
156 Effect.gen(function* () {177 Effect.gen(function* () {
157 const env = yield* createDevRunnerEnv({178 const env = yield* createDevRunnerEnv({
scripts/dev-runner.ts+1
...@@ -388,6 +388,7 @@ export function createDevRunnerEnv({...@@ -388,6 +388,7 @@ export function createDevRunnerEnv({
388 delete output.T3CODE_MODE;388 delete output.T3CODE_MODE;
389 delete output.T3CODE_NO_BROWSER;389 delete output.T3CODE_NO_BROWSER;
390 delete output.T3CODE_HOST;390 delete output.T3CODE_HOST;
391 delete output.T3CODE_DEV_AUTH_TOKEN;
391 }392 }
392393
393 if (!isDesktopMode && host !== undefined) {394 if (!isDesktopMode && host !== undefined) {