582 lines · 23.5 KB · TypeScript
RawDownload
import * as NodeServices from "@effect/platform-node/NodeServices";import { EnvironmentId } from "@t3tools/contracts";import { expect, it } from "@effect/vitest";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Queue from "effect/Queue";import * as Stream from "effect/Stream";import * as TestClock from "effect/testing/TestClock";import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts";import * as ServerEnvironment from "../environment/ServerEnvironment.ts";import { PersistenceSqlError } from "../persistence/Errors.ts";import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";import * as AuthSessions from "../persistence/AuthSessions.ts";import * as SessionStore from "./SessionStore.ts";import * as ServerSecretStore from "./ServerSecretStore.ts"; const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>  Layer.effect(    ServerConfig.ServerConfig,    Effect.gen(function* () {      const config = yield* ServerConfig.ServerConfig;      return {        ...config,        ...overrides,      } satisfies ServerConfig.ServerConfig["Service"];    }),  ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); const makeServerEnvironmentLayer = (environmentId: EnvironmentId) =>  Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {    getEnvironmentId: Effect.succeed(environmentId),  }); const makeSessionStoreLayer = (  overrides?: Partial<ServerConfig.ServerConfig["Service"]>,  environmentId = EnvironmentId.make("test-environment"),) =>  SessionStore.layer.pipe(    Layer.provide(SqlitePersistenceMemory),    Layer.provide(ServerSecretStore.layer),    Layer.provide(makeServerEnvironmentLayer(environmentId)),    Layer.provide(makeServerConfigLayer(overrides)),  ); const relaySessionInput = {  subject: "managed-relay-bootstrap",  method: "dpop-access-token",  proofKeyThumbprint: "relay-proof-key",  ttl: Duration.hours(1),  client: { label: "Relay desktop", deviceType: "desktop" },} as const; const repositoryFailure = new PersistenceSqlError({  operation: "AuthSessionRepository.getById:query",  detail: "sqlite is unavailable",}); const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, {  create: () => Effect.void,  createReplacingActive: () => Effect.succeed([]),  getById: () => Effect.fail(repositoryFailure),  listActive: () => Effect.succeed([]),  revoke: () => Effect.fail(repositoryFailure),  revokeAllExcept: () => Effect.fail(repositoryFailure),  setLastConnectedAt: () => Effect.void,  setClientConnection: () => Effect.void,}); const failingSessionLookupCredentialLayer = Layer.effect(  SessionStore.SessionStore,  SessionStore.make,).pipe(  Layer.provide(failingSessionLookupRepositoryLayer),  Layer.provide(ServerSecretStore.layer),  Layer.provide(SqlitePersistenceMemory),  Layer.provide(makeServerEnvironmentLayer(EnvironmentId.make("test-environment"))),  Layer.provide(makeServerConfigLayer()),); it.layer(NodeServices.layer)("SessionStore.layer", (it) => {  it.effect("keys remote cookies by environment identity instead of state directory", () =>    Effect.gen(function* () {      const cookieName = (stateDir: string, environmentId: EnvironmentId) =>        Effect.gen(function* () {          const sessions = yield* SessionStore.SessionStore;          return sessions.cookieName;        }).pipe(          Effect.provide(            makeSessionStoreLayer({ mode: "web", host: "192.168.1.50", stateDir }, environmentId),          ),        );       const original = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-one"));      const moved = yield* cookieName("/srv/t3-moved", EnvironmentId.make("environment-one"));      const other = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-two"));       expect(moved).toBe(original);      expect(other).not.toBe(original);    }),  );   it.effect("issues and verifies signed browser session tokens", () =>    Effect.gen(function* () {      const sessions = yield* SessionStore.SessionStore;      const issued = yield* sessions.issue({        subject: "desktop-bootstrap",        scopes: ["orchestration:read", "access:write"],        client: {          label: "Desktop app",          deviceType: "desktop",          os: "macOS",          browser: "Electron",          ipAddress: "127.0.0.1",        },      });      const verified = yield* sessions.verify(issued.token);       expect(verified.method).toBe("browser-session-cookie");      expect(verified.subject).toBe("desktop-bootstrap");      expect(verified.scopes).toEqual(["orchestration:read", "access:write"]);      expect(verified.client.label).toBe("Desktop app");      expect(verified.client.browser).toBe("Electron");      expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString());    }).pipe(Effect.provide(makeSessionStoreLayer())),  );  it.effect("rejects malformed session tokens", () =>    Effect.gen(function* () {      const sessions = yield* SessionStore.SessionStore;      const error = yield* Effect.flip(sessions.verify("not-a-session-token"));       expect(error._tag).toBe("MalformedSessionTokenError");      expect(error.message).toContain("Malformed session token");    }).pipe(Effect.provide(makeSessionStoreLayer())),  );  it.effect("preserves repository failures while verifying session and websocket credentials", () =>    Effect.gen(function* () {      const sessions = yield* SessionStore.SessionStore;      const issued = yield* sessions.issue({        method: "bearer-access-token",        subject: "repository-failure",      });      const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);       const sessionError = yield* Effect.flip(sessions.verify(issued.token));      const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));      const revokeError = yield* Effect.flip(sessions.revoke(issued.sessionId));      const revokeOthersError = yield* Effect.flip(sessions.revokeAllExcept(issued.sessionId));       expect(sessionError._tag).toBe("SessionCredentialVerificationError");      expect(websocketError._tag).toBe("WebSocketTokenVerificationError");      expect(sessionError.cause).toBe(repositoryFailure);      expect(websocketError.cause).toBe(repositoryFailure);      if (sessionError._tag === "SessionCredentialVerificationError") {        expect(sessionError.sessionId).toBe(issued.sessionId);      }      if (websocketError._tag === "WebSocketTokenVerificationError") {        expect(websocketError.sessionId).toBe(issued.sessionId);      }      expect(revokeError).toMatchObject({        _tag: "SessionRevocationError",        sessionId: issued.sessionId,        cause: repositoryFailure,      });      expect(revokeOthersError).toMatchObject({        _tag: "OtherSessionsRevocationError",        currentSessionId: issued.sessionId,        cause: repositoryFailure,      });    }).pipe(Effect.provide(failingSessionLookupCredentialLayer)),  );  it.effect("verifies session tokens against the Effect clock", () =>    Effect.gen(function* () {      const sessions = yield* SessionStore.SessionStore;      const issued = yield* sessions.issue({        method: "bearer-access-token",        subject: "test-clock",      });      const verified = yield* sessions.verify(issued.token);       expect(verified.method).toBe("bearer-access-token");      expect(verified.subject).toBe("test-clock");      expect(verified.scopes).toEqual([        "orchestration:read",        "orchestration:operate",        "terminal:operate",        "review:write",        "relay:read",      ]);    }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),  );   it.effect("atomically replaces active sessions with the same subject and method", () =>    Effect.gen(function* () {      const sessions = yield* SessionStore.SessionStore;      const browser = yield* sessions.issue({        subject: "desktop-bootstrap",        method: "browser-session-cookie",      });      const [firstBearer, secondBearer] = yield* Effect.all(        [          sessions.issue({            subject: "desktop-bootstrap",            method: "bearer-access-token",            replaceActiveForSubjectAndMethod: true,          }),          sessions.issue({            subject: "desktop-bootstrap",            method: "bearer-access-token",            replaceActiveForSubjectAndMethod: true,          }),        ],        { concurrency: "unbounded" },      );       const active = yield* sessions.listActive();      const bearerVerification = yield* Effect.all([        sessions.verify(firstBearer.token).pipe(Effect.option),        sessions.verify(secondBearer.token).pipe(Effect.option),      ]);       expect(active).toHaveLength(2);      expect(active.find((entry) => entry.sessionId === browser.sessionId)).toBeDefined();      expect(        active.filter(          (entry) =>            entry.subject === "desktop-bootstrap" && entry.method === "bearer-access-token",        ),      ).toHaveLength(1);      expect(bearerVerification.filter(Option.isSome)).toHaveLength(1);    }).pipe(Effect.provide(makeSessionStoreLayer())),  );   it.effect("keeps the previous desktop session valid when replacement fails", () =>    Effect.gen(function* () {      const sessions = yield* SessionStore.SessionStore;      const sql = yield* SqlClient.SqlClient;      const previous = yield* sessions.issue({        subject: "desktop-bootstrap",        method: "bearer-access-token",      });      yield* sql`        CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions        BEGIN          SELECT RAISE(ABORT, 'simulated insert failure');        END      `;       const error = yield* sessions        .issue({          subject: "desktop-bootstrap",          method: "bearer-access-token",          replaceActiveForSubjectAndMethod: true,        })        .pipe(Effect.flip);       expect(error._tag).toBe("SessionCredentialIssueError");      expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId);      expect((yield* sessions.listActive()).map((session) => session.sessionId)).toEqual([        previous.sessionId,      ]);    }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))),  );   it.effect("rejects websocket tokens once the parent session has expired", () =>    Effect.gen(function* () {      const sessions = yield* SessionStore.SessionStore;      const issued = yield* sessions.issue({        method: "bearer-access-token",        subject: "short-lived",        ttl: Duration.seconds(1),      });      const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);       yield* TestClock.adjust(Duration.seconds(2));       const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));      expect(error._tag).toBe("WebSocketSessionExpiredError");      if (error._tag === "WebSocketSessionExpiredError") {        expect(error.sessionId).toBe(issued.sessionId);        expect(error.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds);        expect(error.observedAt.epochMilliseconds).toBeGreaterThan(          error.expiresAt.epochMilliseconds,        );      }    }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),  );   it.effect("includes expiry context when session and websocket tokens expire", () =>    Effect.gen(function* () {      const sessions = yield* SessionStore.SessionStore;      const issued = yield* sessions.issue({        method: "bearer-access-token",        subject: "short-lived-token",        ttl: Duration.seconds(1),      });      const websocket = yield* sessions.issueWebSocketToken(issued.sessionId, {        ttl: Duration.seconds(1),      });       yield* TestClock.adjust(Duration.seconds(2));       const sessionError = yield* Effect.flip(sessions.verify(issued.token));      const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));       expect(sessionError._tag).toBe("SessionTokenExpiredError");      if (sessionError._tag === "SessionTokenExpiredError") {        expect(sessionError.sessionId).toBe(issued.sessionId);        expect(sessionError.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds);        expect(sessionError.observedAt.epochMilliseconds).toBeGreaterThan(          sessionError.expiresAt.epochMilliseconds,        );      }      expect(websocketError._tag).toBe("WebSocketTokenExpiredError");      if (websocketError._tag === "WebSocketTokenExpiredError") {        expect(websocketError.sessionId).toBe(issued.sessionId);        expect(websocketError.expiresAt.epochMilliseconds).toBe(          websocket.expiresAt.epochMilliseconds,        );        expect(websocketError.observedAt.epochMilliseconds).toBeGreaterThan(          websocketError.expiresAt.epochMilliseconds,        );      }    }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),  );   it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () =>    Effect.gen(function* () {      const sessions = yield* SessionStore.SessionStore;      const administrative = yield* sessions.issue({        subject: "desktop-bootstrap",        scopes: ["orchestration:read", "access:write"],        client: {          label: "Desktop app",          deviceType: "desktop",          os: "macOS",          browser: "Electron",        },      });      const client = yield* sessions.issue({        subject: "one-time-token",        scopes: ["orchestration:read"],        client: {          label: "Julius iPhone",          deviceType: "mobile",          os: "iOS",          browser: "Safari",          ipAddress: "192.168.1.88",        },      });      const clientWebSocket = yield* sessions.issueWebSocketToken(client.sessionId);       yield* sessions.markConnected(client.sessionId);      const beforeRevoke = yield* sessions.listActive();      const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId);      const afterRevoke = yield* sessions.listActive();      const revokedClient = yield* Effect.flip(sessions.verify(client.token));      const revokedClientWebSocket = yield* Effect.flip(        sessions.verifyWebSocketToken(clientWebSocket.token),      );       expect(beforeRevoke).toHaveLength(2);      expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(        true,      );      expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe(        "Julius iPhone",      );      expect(        beforeRevoke.find((entry) => entry.sessionId === administrative.sessionId)?.client          .deviceType,      ).toBe("desktop");      expect(revokedCount).toBe(1);      expect(afterRevoke).toHaveLength(1);      expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId);      expect(revokedClient._tag).toBe("SessionTokenRevokedError");      if (revokedClient._tag === "SessionTokenRevokedError") {        expect(revokedClient.sessionId).toBe(client.sessionId);        expect(revokedClient.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0);      }      expect(revokedClientWebSocket._tag).toBe("WebSocketSessionRevokedError");      if (revokedClientWebSocket._tag === "WebSocketSessionRevokedError") {        expect(revokedClientWebSocket.sessionId).toBe(client.sessionId);        expect(revokedClientWebSocket.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0);      }    }).pipe(Effect.provide(makeSessionStoreLayer())),  );   it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () =>    Effect.gen(function* () {      const sessions = yield* SessionStore.SessionStore;      const issued = yield* sessions.issue({        subject: "reconnect-test",        method: "bearer-access-token",      });       const beforeConnect = yield* sessions.listActive();      expect(beforeConnect[0]?.lastConnectedAt).toBeNull();       yield* TestClock.adjust(Duration.seconds(1));      yield* sessions.markConnected(issued.sessionId);      const firstConnect = yield* sessions.listActive();      const firstConnectedAt = firstConnect[0]?.lastConnectedAt;       expect(firstConnect[0]?.connected).toBe(true);      expect(firstConnectedAt).not.toBeNull();       yield* TestClock.adjust(Duration.seconds(1));      yield* sessions.markConnected(issued.sessionId);      const stillConnected = yield* sessions.listActive();       expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());       yield* sessions.markDisconnected(issued.sessionId);      yield* sessions.markDisconnected(issued.sessionId);      const afterDisconnect = yield* sessions.listActive();       expect(afterDisconnect[0]?.connected).toBe(false);      expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());       yield* TestClock.adjust(Duration.seconds(1));      yield* sessions.markConnected(issued.sessionId);      const afterReconnect = yield* sessions.listActive();       expect(afterReconnect[0]?.connected).toBe(true);      expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull();      expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());    }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),  );   it.effect("keeps connected relay sessions visible through expiry and HTTP renewal", () =>    Effect.gen(function* () {      const sessions = yield* SessionStore.SessionStore;      const original = yield* sessions.issue(relaySessionInput);      const websocket = yield* sessions.issueWebSocketToken(original.sessionId, {        ttl: Duration.hours(2),      });      yield* sessions.verifyWebSocketToken(websocket.token);      yield* sessions.markConnected(original.sessionId);      const beforeExpiry = yield* sessions.listActive();      expect(beforeExpiry).toHaveLength(1);      expect(beforeExpiry[0]?.connected).toBe(true);       yield* TestClock.adjust(Duration.minutes(61));       expect(yield* sessions.listActive()).toEqual(beforeExpiry);      expect(yield* Effect.flip(sessions.verify(original.token))).toMatchObject({        _tag: "SessionTokenExpiredError",      });      expect(yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token))).toMatchObject({        _tag: "WebSocketSessionExpiredError",      });       const renewed = yield* sessions.issue(relaySessionInput);      const afterRenewal = yield* sessions.listActive();      expect(renewed.sessionId).not.toBe(original.sessionId);      expect(afterRenewal).toHaveLength(2);      expect(afterRenewal).toEqual(        expect.arrayContaining([          beforeExpiry[0],          expect.objectContaining({            sessionId: renewed.sessionId,            connected: false,            lastConnectedAt: null,          }),        ]),      );    }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),  );   it.effect.each([1, 2])(    "removes an expired session from listings and updates after its last of %s sockets closes",    (socketCount) =>      Effect.gen(function* () {        const sessions = yield* SessionStore.SessionStore;        const issued = yield* sessions.issue(relaySessionInput);        const changes = yield* Queue.unbounded<SessionStore.SessionCredentialChange>();        yield* sessions.streamChanges.pipe(          Stream.runForEach((change) => Queue.offer(changes, change)),          Effect.forkScoped({ startImmediately: true }),        );        for (let index = 0; index < socketCount; index += 1) {          yield* sessions.markConnected(issued.sessionId);          expect(yield* Queue.take(changes)).toMatchObject({            type: "clientUpserted",            clientSession: { sessionId: issued.sessionId, connected: true },          });        }         yield* TestClock.adjust(Duration.minutes(61));         for (let remaining = socketCount - 1; remaining >= 0; remaining -= 1) {          yield* sessions.markDisconnected(issued.sessionId);          const change = yield* Queue.take(changes);          const listed = yield* sessions.listActive();          if (remaining > 0) {            expect(change).toMatchObject({

Lines 1–500 of 582.