| author | |
| committer | |
| log | c78ae50a5a5fdf8f42d0aaa0103b26ee836f0cfc |
| tree | 715487357eb5d743673173cefa2bfb062e0ead70 |
| parent | 9ecfc07a8b3acadd1612665b6e24425f625480de |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>18 files changed, 484 insertions(+), 87 deletions(-)
apps/server/src/auth/EnvironmentAuth.test.ts+16| ... | ... | @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | |
| 7 | 7 | import * as ServerConfig from "../config.ts"; |
| 8 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | |
| 8 | 9 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; |
| 9 | 10 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 10 | 11 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| ... | ... | @@ -34,6 +35,7 @@ const makeEnvironmentAuthLayer = (overrides?: Partial<ServerConfig.ServerConfig[ |
| 34 | 35 | EnvironmentAuth.layer.pipe( |
| 35 | 36 | Layer.provide(SqlitePersistenceMemory), |
| 36 | 37 | Layer.provide(ServerSecretStore.layer), |
| 38 | Layer.provide(ServerEnvironment.identityLayer), | |
| 37 | 39 | Layer.provide(makeServerConfigLayer(overrides)), |
| 38 | 40 | ); |
| 39 | 41 | |
| ... | ... | @@ -109,6 +111,20 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 109 | 111 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), |
| 110 | 112 | ); |
| 111 | 113 | |
| 114 | it.effect("prefers a bearer token over a stale legacy cookie", () => | |
| 115 | Effect.gen(function* () { | |
| 116 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 117 | const sessions = yield* SessionStore.SessionStore; | |
| 118 | const bearer = yield* serverAuth.issueSession(); | |
| 119 | const verified = yield* serverAuth.authenticateHttpRequest({ | |
| 120 | cookies: { [sessions.legacyCookieName ?? "t3_session"]: "stale" }, | |
| 121 | headers: { authorization: `Bearer ${bearer.token}` }, | |
| 122 | } as never); | |
| 123 | ||
| 124 | expect(verified.sessionId).toBe(bearer.sessionId); | |
| 125 | }).pipe(Effect.provide(makeEnvironmentAuthLayer({ mode: "web", host: "192.168.1.50" }))), | |
| 126 | ); | |
| 127 | ||
| 112 | 128 | it.effect("does not exchange ordinary pairing grants for administrative access tokens", () => |
| 113 | 129 | Effect.gen(function* () { |
| 114 | 130 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
apps/server/src/auth/EnvironmentAuth.ts+42-8| ... | ... | @@ -30,6 +30,7 @@ import * as Option from "effect/Option"; |
| 30 | 30 | import * as Schema from "effect/Schema"; |
| 31 | 31 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 32 | 32 | |
| 33 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | |
| 33 | 34 | import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; |
| 34 | 35 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 35 | 36 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| ... | ... | @@ -562,6 +563,34 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | |
| 562 | 563 | return token.length > 0 ? token : null; |
| 563 | 564 | } |
| 564 | 565 | |
| 566 | export function selectRequestCredential( | |
| 567 | request: HttpServerRequest.HttpServerRequest, | |
| 568 | cookieName: string, | |
| 569 | legacyCookieName: string | undefined, | |
| 570 | ) { | |
| 571 | const cookieToken = request.cookies[cookieName]; | |
| 572 | if (cookieToken !== undefined) { | |
| 573 | return { token: cookieToken, source: "cookie" } as const; | |
| 574 | } | |
| 575 | ||
| 576 | const bearerToken = parseBearerToken(request); | |
| 577 | if (bearerToken !== null) { | |
| 578 | return { token: bearerToken, source: "bearer" } as const; | |
| 579 | } | |
| 580 | ||
| 581 | const dpopToken = parseDpopToken(request); | |
| 582 | if (dpopToken !== null) { | |
| 583 | return { token: dpopToken, source: "dpop" } as const; | |
| 584 | } | |
| 585 | ||
| 586 | const legacyToken = legacyCookieName ? request.cookies[legacyCookieName] : undefined; | |
| 587 | if (legacyToken !== undefined) { | |
| 588 | return { token: legacyToken, source: "legacy-cookie" } as const; | |
| 589 | } | |
| 590 | ||
| 591 | return undefined; | |
| 592 | } | |
| 593 | ||
| 565 | 594 | export const make = Effect.gen(function* () { |
| 566 | 595 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; |
| 567 | 596 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| ... | ... | @@ -600,17 +629,19 @@ export const make = Effect.gen(function* () { |
| 600 | 629 | const authenticateRequest = ( |
| 601 | 630 | request: HttpServerRequest.HttpServerRequest, |
| 602 | 631 | ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => { |
| 603 | const cookieToken = request.cookies[sessions.cookieName]; | |
| 604 | const bearerToken = parseBearerToken(request); | |
| 605 | const dpopToken = parseDpopToken(request); | |
| 606 | const credential = cookieToken ?? bearerToken ?? dpopToken; | |
| 607 | if (!credential) { | |
| 632 | const credential = selectRequestCredential( | |
| 633 | request, | |
| 634 | sessions.cookieName, | |
| 635 | sessions.legacyCookieName, | |
| 636 | ); | |
| 637 | if (!credential?.token) { | |
| 608 | 638 | return Effect.fail(new ServerAuthMissingCredentialError({})); |
| 609 | 639 | } |
| 610 | return authenticateToken(credential).pipe( | |
| 640 | const dpopToken = parseDpopToken(request); | |
| 641 | return authenticateToken(credential.token).pipe( | |
| 611 | 642 | Effect.flatMap((session) => { |
| 612 | 643 | if (session.proofKeyThumbprint) { |
| 613 | if (!dpopToken || dpopToken !== credential) { | |
| 644 | if (!dpopToken || dpopToken !== credential.token) { | |
| 614 | 645 | return Effect.fail( |
| 615 | 646 | new ServerAuthInvalidCredentialError({ |
| 616 | 647 | diagnostic: "DPoP-bound access token requires DPoP authorization.", |
| ... | ... | @@ -1003,4 +1034,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe( |
| 1003 | 1034 | |
| 1004 | 1035 | export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); |
| 1005 | 1036 | |
| 1006 | export const runtimeLayer = layer.pipe(Layer.provideMerge(storageLayer)); | |
| 1037 | export const runtimeLayer = layer.pipe( | |
| 1038 | Layer.provideMerge(storageLayer), | |
| 1039 | Layer.provideMerge(ServerEnvironment.identityLayer), | |
| 1040 | ); |
apps/server/src/auth/EnvironmentAuthAdmin.test.ts+2| ... | ... | @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | |
| 6 | 6 | import * as ServerConfig from "../config.ts"; |
| 7 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | |
| 7 | 8 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; |
| 8 | 9 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 9 | 10 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| ... | ... | @@ -35,6 +36,7 @@ const makeEnvironmentAuthLayer = ( |
| 35 | 36 | EnvironmentAuth.layer.pipe( |
| 36 | 37 | Layer.provideMerge(ServerSecretStore.layer), |
| 37 | 38 | Layer.provideMerge(SqlitePersistenceMemory), |
| 39 | Layer.provide(ServerEnvironment.identityLayer), | |
| 38 | 40 | Layer.provide(makeServerConfigLayer(overrides)), |
| 39 | 41 | ); |
| 40 | 42 |
apps/server/src/auth/EnvironmentAuthPolicy.test.ts+4-2| ... | ... | @@ -4,12 +4,14 @@ import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | |
| 6 | 6 | import * as ServerConfig from "../config.ts"; |
| 7 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | |
| 7 | 8 | import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; |
| 8 | 9 | |
| 9 | 10 | const makeEnvironmentAuthPolicyLayer = ( |
| 10 | 11 | overrides?: Partial<ServerConfig.ServerConfig["Service"]>, |
| 11 | 12 | ) => |
| 12 | 13 | EnvironmentAuthPolicy.layer.pipe( |
| 14 | Layer.provide(ServerEnvironment.identityLayer), | |
| 13 | 15 | Layer.provide( |
| 14 | 16 | Layer.effect( |
| 15 | 17 | ServerConfig.ServerConfig, |
| ... | ... | @@ -107,7 +109,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { |
| 107 | 109 | |
| 108 | 110 | expect(descriptor.policy).toBe("remote-reachable"); |
| 109 | 111 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); |
| 110 | expect(descriptor.sessionCookieName).toBe("t3_session"); | |
| 112 | expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); | |
| 111 | 113 | }).pipe( |
| 112 | 114 | Effect.provide( |
| 113 | 115 | makeEnvironmentAuthPolicyLayer({ |
| ... | ... | @@ -143,7 +145,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => { |
| 143 | 145 | const descriptor = yield* policy.getDescriptor(); |
| 144 | 146 | |
| 145 | 147 | expect(descriptor.policy).toBe("remote-reachable"); |
| 146 | expect(descriptor.sessionCookieName).toBe("t3_session"); | |
| 148 | expect(descriptor.sessionCookieName).toMatch(/^t3_session_[a-f0-9]{12}$/); | |
| 147 | 149 | }).pipe( |
| 148 | 150 | Effect.provide( |
| 149 | 151 | makeEnvironmentAuthPolicyLayer({ |
apps/server/src/auth/EnvironmentAuthPolicy.ts+3| ... | ... | @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | |
| 6 | 6 | import * as ServerConfig from "../config.ts"; |
| 7 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | |
| 7 | 8 | import { isRemoteReachableHost, resolveSessionCookieName } from "./utils.ts"; |
| 8 | 9 | |
| 9 | 10 | export class EnvironmentAuthPolicy extends Context.Service< |
| ... | ... | @@ -15,6 +16,7 @@ export class EnvironmentAuthPolicy extends Context.Service< |
| 15 | 16 | |
| 16 | 17 | export const make = Effect.gen(function* () { |
| 17 | 18 | const config = yield* ServerConfig.ServerConfig; |
| 19 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; | |
| 18 | 20 | const isRemoteReachable = isRemoteReachableHost(config.host); |
| 19 | 21 | |
| 20 | 22 | const policy = |
| ... | ... | @@ -42,6 +44,7 @@ export const make = Effect.gen(function* () { |
| 42 | 44 | port: config.port, |
| 43 | 45 | host: config.host, |
| 44 | 46 | instanceKey: config.stateDir, |
| 47 | environmentId: yield* serverEnvironment.getEnvironmentId, | |
| 45 | 48 | development: config.devUrl !== undefined, |
| 46 | 49 | }), |
| 47 | 50 | }; |
apps/server/src/auth/SessionStore.test.ts+33-4| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | 3 | import { expect, it } from "@effect/vitest"; |
| 3 | 4 | import * as Duration from "effect/Duration"; |
| 4 | 5 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -7,15 +8,14 @@ import * as TestClock from "effect/testing/TestClock"; |
| 7 | 8 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 8 | 9 | |
| 9 | 10 | import * as ServerConfig from "../config.ts"; |
| 11 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | |
| 10 | 12 | import { PersistenceSqlError } from "../persistence/Errors.ts"; |
| 11 | 13 | import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; |
| 12 | 14 | import * as AuthSessions from "../persistence/AuthSessions.ts"; |
| 13 | 15 | import * as SessionStore from "./SessionStore.ts"; |
| 14 | 16 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 15 | 17 | |
| 16 | const makeServerConfigLayer = ( | |
| 17 | overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>, | |
| 18 | ) => | |
| 18 | const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) => | |
| 19 | 19 | Layer.effect( |
| 20 | 20 | ServerConfig.ServerConfig, |
| 21 | 21 | Effect.gen(function* () { |
| ... | ... | @@ -27,12 +27,19 @@ const makeServerConfigLayer = ( |
| 27 | 27 | }), |
| 28 | 28 | ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); |
| 29 | 29 | |
| 30 | const makeServerEnvironmentLayer = (environmentId: EnvironmentId) => | |
| 31 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { | |
| 32 | getEnvironmentId: Effect.succeed(environmentId), | |
| 33 | }); | |
| 34 | ||
| 30 | 35 | const makeSessionStoreLayer = ( |
| 31 | overrides?: Partial<Pick<ServerConfig.ServerConfig["Service"], "desktopBootstrapToken">>, | |
| 36 | overrides?: Partial<ServerConfig.ServerConfig["Service"]>, | |
| 37 | environmentId = EnvironmentId.make("test-environment"), | |
| 32 | 38 | ) => |
| 33 | 39 | SessionStore.layer.pipe( |
| 34 | 40 | Layer.provide(SqlitePersistenceMemory), |
| 35 | 41 | Layer.provide(ServerSecretStore.layer), |
| 42 | Layer.provide(makeServerEnvironmentLayer(environmentId)), | |
| 36 | 43 | Layer.provide(makeServerConfigLayer(overrides)), |
| 37 | 44 | ); |
| 38 | 45 | |
| ... | ... | @@ -58,10 +65,32 @@ const failingSessionLookupCredentialLayer = Layer.effect( |
| 58 | 65 | Layer.provide(failingSessionLookupRepositoryLayer), |
| 59 | 66 | Layer.provide(ServerSecretStore.layer), |
| 60 | 67 | Layer.provide(SqlitePersistenceMemory), |
| 68 | Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))), | |
| 61 | 69 | Layer.provide(makeServerConfigLayer()), |
| 62 | 70 | ); |
| 63 | 71 | |
| 64 | 72 | it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 73 | it.effect("keys remote cookies by environment identity instead of state directory", () => | |
| 74 | Effect.gen(function* () { | |
| 75 | const cookieName = (stateDir: string, environmentId: EnvironmentId) => | |
| 76 | Effect.gen(function* () { | |
| 77 | const sessions = yield* SessionStore.SessionStore; | |
| 78 | return sessions.cookieName; | |
| 79 | }).pipe( | |
| 80 | Effect.provide( | |
| 81 | makeSessionStoreLayer({ mode: "web", host: "192.168.1.50", stateDir }, environmentId), | |
| 82 | ), | |
| 83 | ); | |
| 84 | ||
| 85 | const original = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-one")); | |
| 86 | const moved = yield* cookieName("/srv/t3-moved", EnvironmentId.make("environment-one")); | |
| 87 | const other = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-two")); | |
| 88 | ||
| 89 | expect(moved).toBe(original); | |
| 90 | expect(other).not.toBe(original); | |
| 91 | }), | |
| 92 | ); | |
| 93 | ||
| 65 | 94 | it.effect("issues and verifies signed browser session tokens", () => |
| 66 | 95 | Effect.gen(function* () { |
| 67 | 96 | const sessions = yield* SessionStore.SessionStore; |
apps/server/src/auth/SessionStore.ts+10-2| ... | ... | @@ -21,11 +21,13 @@ import * as Stream from "effect/Stream"; |
| 21 | 21 | import * as Option from "effect/Option"; |
| 22 | 22 | |
| 23 | 23 | import * as ServerConfig from "../config.ts"; |
| 24 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; | |
| 24 | 25 | import * as AuthSessions from "../persistence/AuthSessions.ts"; |
| 25 | 26 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 26 | 27 | import { |
| 27 | 28 | base64UrlDecodeUtf8, |
| 28 | 29 | base64UrlEncode, |
| 30 | resolveLegacySessionCookieName, | |
| 29 | 31 | resolveSessionCookieName, |
| 30 | 32 | signPayload, |
| 31 | 33 | timingSafeEqualBase64Url, |
| ... | ... | @@ -360,6 +362,7 @@ export class SessionStore extends Context.Service< |
| 360 | 362 | SessionStore, |
| 361 | 363 | { |
| 362 | 364 | readonly cookieName: string; |
| 365 | readonly legacyCookieName: string | undefined; | |
| 363 | 366 | readonly issue: (input?: { |
| 364 | 367 | readonly ttl?: Duration.Duration; |
| 365 | 368 | readonly subject?: string; |
| ... | ... | @@ -470,18 +473,22 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli |
| 470 | 473 | export const make = Effect.gen(function* () { |
| 471 | 474 | const crypto = yield* Crypto.Crypto; |
| 472 | 475 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 476 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; | |
| 473 | 477 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| 474 | 478 | const authSessions = yield* AuthSessions.AuthSessionRepository; |
| 475 | 479 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); |
| 476 | 480 | const connectedSessionsRef = yield* Ref.make(new Map<string, number>()); |
| 477 | 481 | const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); |
| 478 | const cookieName = resolveSessionCookieName({ | |
| 482 | const cookieInput = { | |
| 479 | 483 | mode: serverConfig.mode, |
| 480 | 484 | port: serverConfig.port, |
| 481 | 485 | host: serverConfig.host, |
| 482 | 486 | instanceKey: serverConfig.stateDir, |
| 487 | environmentId: yield* serverEnvironment.getEnvironmentId, | |
| 483 | 488 | development: serverConfig.devUrl !== undefined, |
| 484 | }); | |
| 489 | } as const; | |
| 490 | const cookieName = resolveSessionCookieName(cookieInput); | |
| 491 | const legacyCookieName = resolveLegacySessionCookieName(cookieInput); | |
| 485 | 492 | |
| 486 | 493 | const emitUpsert = (clientSession: AuthClientSession) => |
| 487 | 494 | PubSub.publish(changesPubSub, { |
| ... | ... | @@ -930,6 +937,7 @@ export const make = Effect.gen(function* () { |
| 930 | 937 | |
| 931 | 938 | return SessionStore.of({ |
| 932 | 939 | cookieName, |
| 940 | legacyCookieName, | |
| 933 | 941 | issue, |
| 934 | 942 | verify, |
| 935 | 943 | issueWebSocketToken, |
apps/server/src/auth/http.ts+37-12| ... | ... | @@ -171,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, |
| 171 | 171 | }); |
| 172 | 172 | } |
| 173 | 173 | |
| 174 | const appendSessionCookie = (cookieName: string, token: string, expiresAt: DateTime.DateTime) => | |
| 175 | Effect.fromResult( | |
| 176 | Cookies.set(Cookies.empty, cookieName, token, { | |
| 177 | expires: DateTime.toDate(expiresAt), | |
| 178 | httpOnly: true, | |
| 179 | path: "/", | |
| 180 | sameSite: "lax", | |
| 181 | }), | |
| 182 | ).pipe( | |
| 183 | Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")), | |
| 184 | Effect.flatMap((cookies) => | |
| 185 | HttpEffect.appendPreResponseHandler((_request, response) => | |
| 186 | Effect.succeed(HttpServerResponse.mergeCookies(response, cookies)), | |
| 187 | ), | |
| 188 | ), | |
| 189 | ); | |
| 190 | ||
| 174 | 191 | export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( |
| 175 | 192 | scope: AuthEnvironmentScope, |
| 176 | 193 | ) { |
| ... | ... | @@ -224,7 +241,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 224 | 241 | function* (args) { |
| 225 | 242 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 226 | 243 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 227 | return yield* serverAuth.getSessionState(request); | |
| 244 | const result = yield* serverAuth.getSessionState(request); | |
| 245 | const credential = EnvironmentAuth.selectRequestCredential( | |
| 246 | request, | |
| 247 | sessions.cookieName, | |
| 248 | sessions.legacyCookieName, | |
| 249 | ); | |
| 250 | if ( | |
| 251 | credential?.source === "legacy-cookie" && | |
| 252 | result.authenticated && | |
| 253 | result.sessionMethod === "browser-session-cookie" && | |
| 254 | result.expiresAt | |
| 255 | ) { | |
| 256 | yield* appendSessionCookie(sessions.cookieName, credential.token, result.expiresAt); | |
| 257 | yield* appendCredentialResponseHeaders; | |
| 258 | } | |
| 259 | return result; | |
| 228 | 260 | }, |
| 229 | 261 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 230 | 262 | failEnvironmentInternal("internal_error", error), |
| ... | ... | @@ -241,17 +273,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 241 | 273 | args.payload.credential, |
| 242 | 274 | deriveAuthClientMetadata({ request }), |
| 243 | 275 | ); |
| 244 | const sessionCookies = yield* Effect.fromResult( | |
| 245 | Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, { | |
| 246 | expires: DateTime.toDate(result.response.expiresAt), | |
| 247 | httpOnly: true, | |
| 248 | path: "/", | |
| 249 | sameSite: "lax", | |
| 250 | }), | |
| 251 | ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed"))); | |
| 252 | ||
| 253 | yield* HttpEffect.appendPreResponseHandler((_request, response) => | |
| 254 | Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), | |
| 276 | yield* appendSessionCookie( | |
| 277 | sessions.cookieName, | |
| 278 | result.sessionToken, | |
| 279 | result.response.expiresAt, | |
| 255 | 280 | ); |
| 256 | 281 | yield* appendCredentialResponseHeaders; |
| 257 | 282 | return result.response; |
apps/server/src/auth/utils.test.ts+46-19| ... | ... | @@ -64,6 +64,7 @@ describe("session cookie isolation", () => { |
| 64 | 64 | port: 5775, |
| 65 | 65 | host: "127.0.0.1", |
| 66 | 66 | instanceKey: "/tmp/t3-agent-one", |
| 67 | environmentId: "environment-one", | |
| 67 | 68 | development: true, |
| 68 | 69 | }); |
| 69 | 70 | const second = resolveSessionCookieName({ |
| ... | ... | @@ -71,6 +72,7 @@ describe("session cookie isolation", () => { |
| 71 | 72 | port: 5775, |
| 72 | 73 | host: "127.0.0.1", |
| 73 | 74 | instanceKey: "/tmp/t3-agent-two", |
| 75 | environmentId: "environment-two", | |
| 74 | 76 | development: true, |
| 75 | 77 | }); |
| 76 | 78 | |
| ... | ... | @@ -79,25 +81,48 @@ describe("session cookie isolation", () => { |
| 79 | 81 | expect(first).not.toBe(second); |
| 80 | 82 | }); |
| 81 | 83 | |
| 82 | it("keeps the hosted web cookie stable across server instances", () => { | |
| 83 | expect( | |
| 84 | resolveSessionCookieName({ | |
| 85 | mode: "web", | |
| 86 | port: 8080, | |
| 87 | host: "0.0.0.0", | |
| 88 | instanceKey: "/srv/release-a", | |
| 89 | development: false, | |
| 90 | }), | |
| 91 | ).toBe("t3_session"); | |
| 92 | expect( | |
| 93 | resolveSessionCookieName({ | |
| 94 | mode: "web", | |
| 95 | port: 9090, | |
| 96 | host: "app.example.com", | |
| 97 | instanceKey: "/srv/release-b", | |
| 98 | development: false, | |
| 99 | }), | |
| 100 | ).toBe("t3_session"); | |
| 84 | it("isolates remote web servers by server state", () => { | |
| 85 | const first = resolveSessionCookieName({ | |
| 86 | mode: "web", | |
| 87 | port: 3773, | |
| 88 | host: "192.168.1.50", | |
| 89 | instanceKey: "/srv/t3-one", | |
| 90 | environmentId: "environment-one", | |
| 91 | development: false, | |
| 92 | }); | |
| 93 | const second = resolveSessionCookieName({ | |
| 94 | mode: "web", | |
| 95 | port: 5775, | |
| 96 | host: "192.168.1.50", | |
| 97 | instanceKey: "/srv/t3-two", | |
| 98 | environmentId: "environment-two", | |
| 99 | development: false, | |
| 100 | }); | |
| 101 | ||
| 102 | expect(first).toMatch(/^t3_session_[a-f0-9]{12}$/); | |
| 103 | expect(second).toMatch(/^t3_session_[a-f0-9]{12}$/); | |
| 104 | expect(first).not.toBe(second); | |
| 105 | }); | |
| 106 | ||
| 107 | it("keeps a remote web server cookie stable across port changes", () => { | |
| 108 | const first = resolveSessionCookieName({ | |
| 109 | mode: "web", | |
| 110 | port: 8080, | |
| 111 | host: "0.0.0.0", | |
| 112 | instanceKey: "/srv/t3", | |
| 113 | environmentId: "environment-one", | |
| 114 | development: false, | |
| 115 | }); | |
| 116 | const second = resolveSessionCookieName({ | |
| 117 | mode: "web", | |
| 118 | port: 9090, | |
| 119 | host: "app.example.com", | |
| 120 | instanceKey: "/srv/t3", | |
| 121 | environmentId: "environment-one", | |
| 122 | development: false, | |
| 123 | }); | |
| 124 | ||
| 125 | expect(first).toBe(second); | |
| 101 | 126 | }); |
| 102 | 127 | |
| 103 | 128 | it("retains desktop port scoping", () => { |
| ... | ... | @@ -107,6 +132,7 @@ describe("session cookie isolation", () => { |
| 107 | 132 | port: 3773, |
| 108 | 133 | host: "127.0.0.1", |
| 109 | 134 | instanceKey: "/tmp/desktop", |
| 135 | environmentId: "environment-one", | |
| 110 | 136 | development: true, |
| 111 | 137 | }), |
| 112 | 138 | ).toBe("t3_session_3773"); |
| ... | ... | @@ -119,6 +145,7 @@ describe("session cookie isolation", () => { |
| 119 | 145 | port: 5775, |
| 120 | 146 | host: "0.0.0.0", |
| 121 | 147 | instanceKey: "/tmp/t3-wildcard-dev", |
| 148 | environmentId: "environment-one", | |
| 122 | 149 | development: true, |
| 123 | 150 | }), |
| 124 | 151 | ).toMatch(/^t3_session_5775_[a-f0-9]{12}$/); |
apps/server/src/auth/utils.ts+24-11| ... | ... | @@ -16,40 +16,53 @@ const SESSION_COOKIE_NAME = "t3_session"; |
| 16 | 16 | * clobbers the first's session and both sides see "Invalid session token |
| 17 | 17 | * signature" until someone clears cookies by hand. |
| 18 | 18 | * |
| 19 | * Two populations qualify, for the same reason but from different causes: | |
| 19 | * Remote web servers use their persisted environment identity and omit the | |
| 20 | * port, so the name survives state-directory moves and public port changes. | |
| 20 | 21 | * |
| 21 | * - **Dev servers** (`devUrl` set), which run several at a time across worktrees. | |
| 22 | * - **Desktop**, which scans upward from 3773 for a free port and binds | |
| 22 | * Desktop scans upward from 3773 for a free port and binds | |
| 23 | 23 | * 127.0.0.1, so a second instance lands on a different port and the same host. |
| 24 | * | |
| 25 | * Hosted deployments keep the stable production name: their public port can | |
| 26 | * change between releases, and scoping it would log every user out. | |
| 27 | 24 | */ |
| 28 | 25 | export function resolveSessionCookieName(input: { |
| 29 | 26 | readonly mode: "web" | "desktop"; |
| 30 | 27 | readonly port: number; |
| 31 | 28 | readonly host: string | undefined; |
| 32 | 29 | readonly instanceKey: string; |
| 30 | readonly environmentId: string; | |
| 33 | 31 | readonly development: boolean; |
| 34 | 32 | }): string { |
| 35 | 33 | if (input.mode === "desktop") { |
| 36 | 34 | return `${SESSION_COOKIE_NAME}_${input.port}`; |
| 37 | 35 | } |
| 38 | 36 | |
| 37 | const instanceHash = NodeCrypto.createHash("sha256") | |
| 38 | .update( | |
| 39 | !input.development && isRemoteReachableHost(input.host) | |
| 40 | ? input.environmentId | |
| 41 | : input.instanceKey, | |
| 42 | ) | |
| 43 | .digest("hex") | |
| 44 | .slice(0, 12); | |
| 45 | ||
| 39 | 46 | if (!input.development && isRemoteReachableHost(input.host)) { |
| 40 | return SESSION_COOKIE_NAME; | |
| 47 | return `${SESSION_COOKIE_NAME}_${instanceHash}`; | |
| 41 | 48 | } |
| 42 | 49 | |
| 43 | 50 | // Cookies are scoped by host, not port. Loopback development servers need an |
| 44 | 51 | // instance-specific name or parallel agents overwrite each other's session, |
| 45 | 52 | // and a server that later reuses the port receives a token signed elsewhere. |
| 46 | const instanceHash = NodeCrypto.createHash("sha256") | |
| 47 | .update(input.instanceKey) | |
| 48 | .digest("hex") | |
| 49 | .slice(0, 12); | |
| 50 | 53 | return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`; |
| 51 | 54 | } |
| 52 | 55 | |
| 56 | export function resolveLegacySessionCookieName(input: { | |
| 57 | readonly mode: "web" | "desktop"; | |
| 58 | readonly host: string | undefined; | |
| 59 | readonly development: boolean; | |
| 60 | }): string | undefined { | |
| 61 | return input.mode === "web" && !input.development && isRemoteReachableHost(input.host) | |
| 62 | ? SESSION_COOKIE_NAME | |
| 63 | : undefined; | |
| 64 | } | |
| 65 | ||
| 53 | 66 | export function isRemoteReachableHost(host: string | undefined): boolean { |
| 54 | 67 | if (host === "0.0.0.0" || host === "::" || host === "[::]") { |
| 55 | 68 | return true; |
apps/server/src/bin.test.ts+27-2| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | ThreadId, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | 15 | import * as NetService from "@t3tools/shared/Net"; |
| 16 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 16 | 17 | import { assert, it } from "@effect/vitest"; |
| 17 | 18 | import * as Effect from "effect/Effect"; |
| 18 | 19 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -26,7 +27,13 @@ import * as TestConsole from "effect/testing/TestConsole"; |
| 26 | 27 | import { Command } from "effect/unstable/cli"; |
| 27 | 28 | |
| 28 | 29 | import { cli, makeCli } from "./bin.ts"; |
| 30 | import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; | |
| 31 | import { | |
| 32 | SERVICE_LAUNCHER_CONTEXT_ENV, | |
| 33 | SERVICE_LAUNCHER_PROTOCOL, | |
| 34 | } from "./cloud/serviceProtocol.ts"; | |
| 29 | 35 | import * as ServerConfig from "./config.ts"; |
| 36 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; | |
| 30 | 37 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 31 | 38 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 32 | 39 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; |
| ... | ... | @@ -42,7 +49,24 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 42 | 49 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 43 | 50 | import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 44 | 51 | |
| 52 | import packageJson from "../package.json" with { type: "json" }; | |
| 53 | ||
| 45 | 54 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 55 | const DisconnectedLauncherChildLayer = Layer.mergeAll( | |
| 56 | Layer.succeed(HostProcessEnvironment, { | |
| 57 | ...process.env, | |
| 58 | [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ | |
| 59 | protocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 60 | childVersion: packageJson.version, | |
| 61 | }), | |
| 62 | }), | |
| 63 | Layer.succeed(ServiceLauncherClient.ServiceLauncherHostProcess, { | |
| 64 | connected: false, | |
| 65 | send: () => false, | |
| 66 | on: () => undefined, | |
| 67 | off: () => undefined, | |
| 68 | }), | |
| 69 | ); | |
| 46 | 70 | class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} |
| 47 | 71 | |
| 48 | 72 | const connectCli = makeCli({ cloudEnabled: true }); |
| ... | ... | @@ -127,6 +151,7 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef |
| 127 | 151 | Layer.provideMerge( |
| 128 | 152 | EnvironmentAuth.layer.pipe( |
| 129 | 153 | Layer.provideMerge(SqlitePersistenceLayerLive), |
| 154 | Layer.provide(ServerEnvironment.identityLayer), | |
| 130 | 155 | Layer.provide(ServerSecretStore.layer), |
| 131 | 156 | ), |
| 132 | 157 | ), |
| ... | ... | @@ -237,7 +262,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { |
| 237 | 262 | assert.equal(status.linked, false); |
| 238 | 263 | assert.equal(status.cloudUserId, null); |
| 239 | 264 | assert.equal(status.relayUrl, null); |
| 240 | }), | |
| 265 | }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), | |
| 241 | 266 | ); |
| 242 | 267 | |
| 243 | 268 | it.effect("reports actionable human-readable headless connect state", () => |
| ... | ... | @@ -408,7 +433,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { |
| 408 | 433 | "relay:write", |
| 409 | 434 | ]); |
| 410 | 435 | assert.equal("token" in (listed[0] ?? {}), false); |
| 411 | }), | |
| 436 | }).pipe(Effect.provide(DisconnectedLauncherChildLayer)), | |
| 412 | 437 | ); |
| 413 | 438 | |
| 414 | 439 | it.effect("rejects invalid ttl values before running auth commands", () => |
apps/server/src/cli/connect.ts+2-3| ... | ... | @@ -337,7 +337,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f |
| 337 | 337 | return { status: "not-authenticated" } satisfies RelayUnlinkResult; |
| 338 | 338 | } |
| 339 | 339 | |
| 340 | const environment = yield* ServerEnvironment.ServerEnvironment; | |
| 340 | const environment = yield* ServerEnvironment.ServerEnvironmentIdentity; | |
| 341 | 341 | const environmentId = yield* environment.getEnvironmentId; |
| 342 | 342 | const relayUrl = yield* relayUrlConfig; |
| 343 | 343 | const httpClient = yield* HttpClient.HttpClient; |
| ... | ... | @@ -432,7 +432,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E |
| 432 | 432 | | HttpClient.HttpClient |
| 433 | 433 | | Prompt.Environment |
| 434 | 434 | | ServerConfig.ServerConfig |
| 435 | | ServerEnvironment.ServerEnvironment | |
| 435 | | ServerEnvironment.ServerEnvironmentIdentity | |
| 436 | 436 | >, |
| 437 | 437 | options?: { |
| 438 | 438 | readonly quietLogs?: boolean; |
| ... | ... | @@ -449,7 +449,6 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E |
| 449 | 449 | ), |
| 450 | 450 | RelayClient.layerCloudflared({ baseDir: config.baseDir }), |
| 451 | 451 | EnvironmentAuth.runtimeLayer, |
| 452 | ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)), | |
| 453 | 452 | bootServiceLayer(config), |
| 454 | 453 | headlessRelayClientTracingLayer, |
| 455 | 454 | ).pipe( |
apps/server/src/cli/pair.test.ts+24-1| ... | ... | @@ -6,6 +6,7 @@ import * as NodePath from "node:path"; |
| 6 | 6 | |
| 7 | 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | 8 | import * as NetService from "@t3tools/shared/Net"; |
| 9 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 9 | 10 | import { assert, describe, expect, it } from "@effect/vitest"; |
| 10 | 11 | import * as Effect from "effect/Effect"; |
| 11 | 12 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -13,6 +14,11 @@ import * as TestConsole from "effect/testing/TestConsole"; |
| 13 | 14 | import { Command } from "effect/unstable/cli"; |
| 14 | 15 | |
| 15 | 16 | import { cli } from "../bin.ts"; |
| 17 | import { | |
| 18 | SERVICE_LAUNCHER_CONTEXT_ENV, | |
| 19 | SERVICE_LAUNCHER_PROTOCOL, | |
| 20 | } from "../cloud/serviceProtocol.ts"; | |
| 21 | import * as ServiceLauncherClient from "../cloud/serviceLauncherClient.ts"; | |
| 16 | 22 | import { |
| 17 | 23 | makePersistedServerRuntimeState, |
| 18 | 24 | persistServerRuntimeState, |
| ... | ... | @@ -24,6 +30,8 @@ import { |
| 24 | 30 | resolveTailscaleLocalTarget, |
| 25 | 31 | } from "./pair.ts"; |
| 26 | 32 | |
| 33 | import packageJson from "../../package.json" with { type: "json" }; | |
| 34 | ||
| 27 | 35 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 28 | 36 | |
| 29 | 37 | const baseState = { |
| ... | ... | @@ -170,7 +178,22 @@ describe("t3 pair", () => { |
| 170 | 178 | assert.equal(credentials.length, 1); |
| 171 | 179 | assert.equal(credentials[0]?.label, "t3 pair"); |
| 172 | 180 | }), |
| 173 | ).pipe(Effect.provide(NodeServices.layer)), | |
| 181 | ).pipe( | |
| 182 | Effect.provide(NodeServices.layer), | |
| 183 | Effect.provideService(HostProcessEnvironment, { | |
| 184 | ...process.env, | |
| 185 | [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({ | |
| 186 | protocol: SERVICE_LAUNCHER_PROTOCOL, | |
| 187 | childVersion: packageJson.version, | |
| 188 | }), | |
| 189 | }), | |
| 190 | Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, { | |
| 191 | connected: false, | |
| 192 | send: () => false, | |
| 193 | on: () => undefined, | |
| 194 | off: () => undefined, | |
| 195 | }), | |
| 196 | ), | |
| 174 | 197 | ); |
| 175 | 198 | |
| 176 | 199 | it.effect("pairs through the recorded dev web URL for dev servers", () => |
apps/server/src/environment/ServerEnvironment.test.ts+76-1| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import * as Crypto from "effect/Crypto"; | |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 3 | 5 | import * as Effect from "effect/Effect"; |
| 4 | 6 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 7 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -71,6 +73,77 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { |
| 71 | 73 | }); |
| 72 | 74 | |
| 73 | 75 | it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 76 | it.effect.each([ | |
| 77 | { name: "missing", content: undefined }, | |
| 78 | { name: "empty", content: "" }, | |
| 79 | { name: "whitespace-only", content: " \t\n" }, | |
| 80 | ])("concurrent initializers recover a $name environment id file", ({ content }) => | |
| 81 | Effect.gen(function* () { | |
| 82 | const fileSystem = yield* FileSystem.FileSystem; | |
| 83 | const crypto = yield* Crypto.Crypto; | |
| 84 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 85 | prefix: "t3-server-environment-concurrent-test-", | |
| 86 | }); | |
| 87 | const serverConfig = yield* makeServerConfig(baseDir); | |
| 88 | yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); | |
| 89 | if (content !== undefined) { | |
| 90 | yield* fileSystem.writeFileString(serverConfig.environmentIdPath, content); | |
| 91 | } | |
| 92 | const bothGenerated = yield* Deferred.make<void>(); | |
| 93 | const bothReadEmpty = yield* Deferred.make<void>(); | |
| 94 | const firstInitialized = yield* Deferred.make<void>(); | |
| 95 | let remaining = 2; | |
| 96 | let emptyReads = 0; | |
| 97 | const readIdentity = Effect.gen(function* () { | |
| 98 | const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; | |
| 99 | return yield* identity.getEnvironmentId; | |
| 100 | }).pipe( | |
| 101 | Effect.tap(() => Deferred.succeed(firstInitialized, undefined)), | |
| 102 | Effect.provide(Layer.fresh(ServerEnvironment.identityLayer)), | |
| 103 | Effect.provideService(ServerConfig.ServerConfig, serverConfig), | |
| 104 | Effect.provideService(FileSystem.FileSystem, { | |
| 105 | ...fileSystem, | |
| 106 | readFileString: (path) => | |
| 107 | fileSystem.readFileString(path).pipe( | |
| 108 | Effect.tap( | |
| 109 | Effect.fn(function* (value) { | |
| 110 | if (path !== serverConfig.environmentIdPath || remaining > 0 || value.trim()) { | |
| 111 | return; | |
| 112 | } | |
| 113 | // Both observe the empty file, but one repairs it after the other has finished. | |
| 114 | if (++emptyReads === 2) { | |
| 115 | yield* Deferred.succeed(bothReadEmpty, undefined); | |
| 116 | yield* Deferred.await(firstInitialized); | |
| 117 | } else { | |
| 118 | yield* Deferred.await(bothReadEmpty); | |
| 119 | } | |
| 120 | }), | |
| 121 | ), | |
| 122 | ), | |
| 123 | }), | |
| 124 | Effect.provideService(Crypto.Crypto, { | |
| 125 | ...crypto, | |
| 126 | randomUUIDv4: Effect.gen(function* () { | |
| 127 | const id = yield* crypto.randomUUIDv4; | |
| 128 | if (--remaining === 0) { | |
| 129 | yield* Deferred.succeed(bothGenerated, undefined); | |
| 130 | } | |
| 131 | yield* Deferred.await(bothGenerated); | |
| 132 | return id; | |
| 133 | }), | |
| 134 | }), | |
| 135 | ); | |
| 136 | ||
| 137 | const [first, second] = yield* Effect.all([readIdentity, readIdentity], { | |
| 138 | concurrency: "unbounded", | |
| 139 | }); | |
| 140 | const persisted = yield* fileSystem.readFileString(serverConfig.environmentIdPath); | |
| 141 | ||
| 142 | expect(first).toBe(second); | |
| 143 | expect(persisted.trim()).toBe(first); | |
| 144 | }), | |
| 145 | ); | |
| 146 | ||
| 74 | 147 | it.effect("persists the environment id across service restarts", () => |
| 75 | 148 | Effect.gen(function* () { |
| 76 | 149 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -153,6 +226,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 153 | 226 | }); |
| 154 | 227 | const serverConfig = yield* makeServerConfig(baseDir); |
| 155 | 228 | const environmentIdPath = serverConfig.environmentIdPath; |
| 229 | const tempPath = `${environmentIdPath}.tmp`; | |
| 156 | 230 | const methodByOperation = { |
| 157 | 231 | check: "exists", |
| 158 | 232 | read: "readFileString", |
| ... | ... | @@ -172,6 +246,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 172 | 246 | exists: () => |
| 173 | 247 | operation === "check" ? Effect.fail(cause) : Effect.succeed(operation === "read"), |
| 174 | 248 | readFileString: () => Effect.fail(cause), |
| 249 | makeTempFileScoped: () => Effect.succeed(tempPath), | |
| 175 | 250 | writeFileString: (path) => { |
| 176 | 251 | writeAttempts.push(path); |
| 177 | 252 | return Effect.fail(cause); |
| ... | ... | @@ -201,7 +276,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 201 | 276 | expect(error.message).toBe( |
| 202 | 277 | `Server environment ID ${operation} failed at '${environmentIdPath}'.`, |
| 203 | 278 | ); |
| 204 | expect(writeAttempts).toEqual(operation === "write" ? [environmentIdPath] : []); | |
| 279 | expect(writeAttempts).toEqual(operation === "write" ? [tempPath] : []); | |
| 205 | 280 | } |
| 206 | 281 | }), |
| 207 | 282 | ); |
apps/server/src/environment/ServerEnvironment.ts+81-21| ... | ... | @@ -24,12 +24,15 @@ import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; |
| 24 | 24 | export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass<ServerEnvironmentIdPersistenceError>()( |
| 25 | 25 | "ServerEnvironmentIdPersistenceError", |
| 26 | 26 | { |
| 27 | operation: Schema.Literals(["check", "read", "write"]), | |
| 27 | operation: Schema.Literals(["check", "read", "write", "initialize"]), | |
| 28 | 28 | environmentIdPath: Schema.String, |
| 29 | cause: Schema.Defect(), | |
| 29 | cause: Schema.optional(Schema.Defect()), | |
| 30 | 30 | }, |
| 31 | 31 | ) { |
| 32 | 32 | override get message(): string { |
| 33 | if (this.operation === "initialize") { | |
| 34 | return `Server environment ID file is missing or empty after initialization at '${this.environmentIdPath}'.`; | |
| 35 | } | |
| 33 | 36 | return `Server environment ID ${this.operation} failed at '${this.environmentIdPath}'.`; |
| 34 | 37 | } |
| 35 | 38 | } |
| ... | ... | @@ -42,6 +45,13 @@ export class ServerEnvironment extends Context.Service< |
| 42 | 45 | } |
| 43 | 46 | >()("t3/environment/ServerEnvironment") {} |
| 44 | 47 | |
| 48 | export class ServerEnvironmentIdentity extends Context.Service< | |
| 49 | ServerEnvironmentIdentity, | |
| 50 | { | |
| 51 | readonly getEnvironmentId: Effect.Effect<EnvironmentId>; | |
| 52 | } | |
| 53 | >()("t3/environment/ServerEnvironment/ServerEnvironmentIdentity") {} | |
| 54 | ||
| 45 | 55 | function platformOs(platform: NodeJS.Platform): ExecutionEnvironmentDescriptor["platform"]["os"] { |
| 46 | 56 | switch (platform) { |
| 47 | 57 | case "darwin": |
| ... | ... | @@ -68,14 +78,10 @@ function platformArch( |
| 68 | 78 | } |
| 69 | 79 | } |
| 70 | 80 | |
| 71 | export const make = Effect.gen(function* () { | |
| 81 | const makeIdentity = Effect.gen(function* () { | |
| 72 | 82 | const fileSystem = yield* FileSystem.FileSystem; |
| 73 | const path = yield* Path.Path; | |
| 74 | 83 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 75 | const secrets = yield* ServerSecretStore.ServerSecretStore; | |
| 76 | 84 | const crypto = yield* Crypto.Crypto; |
| 77 | const hostPlatform = yield* HostProcessPlatform; | |
| 78 | const hostArchitecture = yield* HostProcessArchitecture; | |
| 79 | 85 | |
| 80 | 86 | const readPersistedEnvironmentId = Effect.gen(function* () { |
| 81 | 87 | const exists = yield* fileSystem.exists(serverConfig.environmentIdPath).pipe( |
| ... | ... | @@ -107,17 +113,42 @@ export const make = Effect.gen(function* () { |
| 107 | 113 | return raw.length > 0 ? raw : null; |
| 108 | 114 | }); |
| 109 | 115 | |
| 110 | const persistEnvironmentId = (value: string) => | |
| 111 | fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`).pipe( | |
| 112 | Effect.mapError( | |
| 113 | (cause) => | |
| 114 | new ServerEnvironmentIdPersistenceError({ | |
| 115 | operation: "write", | |
| 116 | environmentIdPath: serverConfig.environmentIdPath, | |
| 117 | cause, | |
| 118 | }), | |
| 119 | ), | |
| 120 | ); | |
| 116 | const persistEnvironmentId = Effect.fn("ServerEnvironmentIdentity.persistEnvironmentId")( | |
| 117 | function* (value: string, mode: "create" | "recover") { | |
| 118 | const destinationPath = | |
| 119 | mode === "recover" | |
| 120 | ? `${serverConfig.environmentIdPath}.recovery` | |
| 121 | : serverConfig.environmentIdPath; | |
| 122 | const tempPath = yield* fileSystem.makeTempFileScoped({ | |
| 123 | directory: serverConfig.stateDir, | |
| 124 | prefix: ".environment-id-", | |
| 125 | }); | |
| 126 | yield* fileSystem.writeFileString(tempPath, `${value}\n`); | |
| 127 | // Publish the completed file without replacing an ID created by another process. | |
| 128 | yield* fileSystem | |
| 129 | .link(tempPath, destinationPath) | |
| 130 | .pipe( | |
| 131 | Effect.catch((cause) => | |
| 132 | cause.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(cause), | |
| 133 | ), | |
| 134 | ); | |
| 135 | if (mode === "recover") { | |
| 136 | // Keep the recovery ID so delayed initializers also publish the same winner. | |
| 137 | yield* fileSystem.remove(tempPath); | |
| 138 | yield* fileSystem.copyFile(destinationPath, tempPath); | |
| 139 | yield* fileSystem.rename(tempPath, serverConfig.environmentIdPath); | |
| 140 | } | |
| 141 | }, | |
| 142 | Effect.scoped, | |
| 143 | Effect.mapError( | |
| 144 | (cause) => | |
| 145 | new ServerEnvironmentIdPersistenceError({ | |
| 146 | operation: "write", | |
| 147 | environmentIdPath: serverConfig.environmentIdPath, | |
| 148 | cause, | |
| 149 | }), | |
| 150 | ), | |
| 151 | ); | |
| 121 | 152 | |
| 122 | 153 | const environmentIdRaw = yield* Effect.gen(function* () { |
| 123 | 154 | const persisted = yield* readPersistedEnvironmentId; |
| ... | ... | @@ -126,11 +157,35 @@ export const make = Effect.gen(function* () { |
| 126 | 157 | } |
| 127 | 158 | |
| 128 | 159 | const generated = yield* crypto.randomUUIDv4; |
| 129 | yield* persistEnvironmentId(generated); | |
| 130 | return generated; | |
| 160 | yield* persistEnvironmentId(generated, "create"); | |
| 161 | let winner = yield* readPersistedEnvironmentId; | |
| 162 | if (winner === null) { | |
| 163 | yield* persistEnvironmentId(generated, "recover"); | |
| 164 | winner = yield* readPersistedEnvironmentId; | |
| 165 | } | |
| 166 | if (winner === null) { | |
| 167 | return yield* new ServerEnvironmentIdPersistenceError({ | |
| 168 | operation: "initialize", | |
| 169 | environmentIdPath: serverConfig.environmentIdPath, | |
| 170 | }); | |
| 171 | } | |
| 172 | return winner; | |
| 131 | 173 | }); |
| 132 | 174 | |
| 133 | 175 | const environmentId = EnvironmentId.make(environmentIdRaw); |
| 176 | return ServerEnvironmentIdentity.of({ | |
| 177 | getEnvironmentId: Effect.succeed(environmentId), | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | export const make = Effect.gen(function* () { | |
| 182 | const path = yield* Path.Path; | |
| 183 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 184 | const secrets = yield* ServerSecretStore.ServerSecretStore; | |
| 185 | const identity = yield* ServerEnvironmentIdentity; | |
| 186 | const hostPlatform = yield* HostProcessPlatform; | |
| 187 | const hostArchitecture = yield* HostProcessArchitecture; | |
| 188 | const environmentId = yield* identity.getEnvironmentId; | |
| 134 | 189 | const cwdBaseName = path.basename(serverConfig.cwd).trim(); |
| 135 | 190 | const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); |
| 136 | 191 | const launcher = yield* resolveServiceLauncherMode(); |
| ... | ... | @@ -179,10 +234,15 @@ export const make = Effect.gen(function* () { |
| 179 | 234 | }); |
| 180 | 235 | }); |
| 181 | 236 | |
| 237 | export const identityLayer = Layer.effect(ServerEnvironmentIdentity, makeIdentity); | |
| 238 | ||
| 182 | 239 | /** |
| 183 | 240 | * ServerEnvironment is acquired from persisted filesystem and host-process |
| 184 | 241 | * state. It intentionally has no fallback Layer.succeed value: callers must |
| 185 | 242 | * provide the external platform services, a ServerConfig, and the |
| 186 | 243 | * ServerSecretStore backing the descriptor's publishing capability. |
| 187 | 244 | */ |
| 188 | export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); | |
| 245 | export const layer = Layer.effect(ServerEnvironment, make).pipe( | |
| 246 | Layer.provideMerge(identityLayer), | |
| 247 | Layer.provide(ProcessRunner.layer), | |
| 248 | ); |
apps/server/src/server.test.ts+47| ... | ... | @@ -291,6 +291,11 @@ const makeAuthTestLayer = () => |
| 291 | 291 | EnvironmentAuth.layer.pipe( |
| 292 | 292 | Layer.provide(SqlitePersistenceMemory), |
| 293 | 293 | Layer.provide(ServerSecretStore.layer), |
| 294 | Layer.provide( | |
| 295 | Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ | |
| 296 | getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), | |
| 297 | }), | |
| 298 | ), | |
| 294 | 299 | ); |
| 295 | 300 | |
| 296 | 301 | const makeBrowserOtlpPayload = (spanName: string) => |
| ... | ... | @@ -1674,6 +1679,48 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1674 | 1679 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1675 | 1680 | ); |
| 1676 | 1681 | |
| 1682 | it.effect("migrates a valid legacy remote-web session cookie", () => | |
| 1683 | Effect.gen(function* () { | |
| 1684 | yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); | |
| 1685 | ||
| 1686 | const { cookie } = yield* bootstrapBrowserSession(); | |
| 1687 | const currentCookie = cookie?.split(";")[0] ?? ""; | |
| 1688 | const legacyCookie = currentCookie.replace(/^t3_session_[^=]+=/, "t3_session="); | |
| 1689 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); | |
| 1690 | const response = yield* fetchEffect(sessionUrl, { | |
| 1691 | headers: { cookie: legacyCookie }, | |
| 1692 | }); | |
| 1693 | const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); | |
| 1694 | ||
| 1695 | assert.equal(body.authenticated, true); | |
| 1696 | assert.equal(response.headers["set-cookie"], cookie); | |
| 1697 | assert.equal(response.headers["cache-control"], "no-store"); | |
| 1698 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1699 | ); | |
| 1700 | ||
| 1701 | it.effect.each(["cookie", "bearer"])( | |
| 1702 | "does not migrate a stale legacy cookie when %s auth succeeds", | |
| 1703 | (source) => | |
| 1704 | Effect.gen(function* () { | |
| 1705 | yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } }); | |
| 1706 | ||
| 1707 | const { cookie } = yield* bootstrapBrowserSession(); | |
| 1708 | const sessionCookie = cookie?.split(";")[0] ?? ""; | |
| 1709 | const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); | |
| 1710 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); | |
| 1711 | const response = yield* fetchEffect(sessionUrl, { | |
| 1712 | headers: | |
| 1713 | source === "cookie" | |
| 1714 | ? { cookie: `${sessionCookie}; t3_session=stale` } | |
| 1715 | : { authorization: `Bearer ${sessionToken}`, cookie: "t3_session=stale" }, | |
| 1716 | }); | |
| 1717 | const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response); | |
| 1718 | ||
| 1719 | assert.equal(body.authenticated, true); | |
| 1720 | assert.isUndefined(response.headers["set-cookie"]); | |
| 1721 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1722 | ); | |
| 1723 | ||
| 1677 | 1724 | it.effect("exchanges a bootstrap grant for a scoped bearer access token", () => |
| 1678 | 1725 | Effect.gen(function* () { |
| 1679 | 1726 | yield* buildAppUnderTest(); |
apps/server/src/server.ts+6-1| ... | ... | @@ -362,8 +362,13 @@ const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( |
| 362 | 362 | Layer.provide(T3ProjectFileLoader.layer), |
| 363 | 363 | ); |
| 364 | 364 | |
| 365 | const ServerEnvironmentLayerLive = ServerEnvironment.layer.pipe( | |
| 366 | Layer.provide(ServerSecretStore.layer), | |
| 367 | ); | |
| 368 | ||
| 365 | 369 | const AuthLayerLive = EnvironmentAuth.layer.pipe( |
| 366 | 370 | Layer.provideMerge(PersistenceLayerLive), |
| 371 | Layer.provide(ServerEnvironmentLayerLive), | |
| 367 | 372 | Layer.provide(ServerSecretStore.layer), |
| 368 | 373 | ); |
| 369 | 374 | |
| ... | ... | @@ -418,7 +423,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 418 | 423 | Layer.provideMerge(WorkspaceLayerLive), |
| 419 | 424 | Layer.provideMerge(ProjectFaviconResolverLayerLive), |
| 420 | 425 | Layer.provideMerge(RepositoryIdentityResolver.layer), |
| 421 | Layer.provideMerge(ServerEnvironment.layer), | |
| 426 | Layer.provideMerge(ServerEnvironmentLayerLive), | |
| 422 | 427 | Layer.provideMerge(AuthLayerLive), |
| 423 | 428 | Layer.provideMerge(ServerSecretStore.layer), |
| 424 | 429 | Layer.provideMerge( |
docs/internals/remote.md+4| ... | ... | @@ -41,6 +41,10 @@ It is identified by a stable `environmentId`, persisted by the server at `<state |
| 41 | 41 | and generated on first start (`apps/server/src/environment/ServerEnvironment.ts`). Desktop, mobile, |
| 42 | 42 | and web all reason about the same concept. |
| 43 | 43 | |
| 44 | Initialization publishes a complete ID atomically. Empty or whitespace-only ID files are repaired | |
| 45 | using a retained `<stateDir>/environment-id.recovery` file so concurrent and delayed repairs choose | |
| 46 | the same ID. Existing nonempty ID files remain authoritative. | |
| 47 | ||
| 44 | 48 | ### Known environments and connection targets |
| 45 | 49 | |
| 46 | 50 | A saved client-side entry for an environment the client knows how to reach. It is not |