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) => 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, 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(); 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({ type: "clientUpserted", clientSession: { sessionId: issued.sessionId, connected: true }, }); expect(listed).toHaveLength(1); expect(listed[0]?.connected).toBe(true); } else { expect(change).toEqual({ type: "clientRemoved", sessionId: issued.sessionId }); expect(listed).toEqual([]); } } }).pipe( Effect.scoped, Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer())), ), ); it.effect.each(["revoke", "revokeAllExcept"] as const)( "removes expired connected sessions with %s", (operation) => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; const administrative = yield* sessions.issue({ subject: "desktop-bootstrap" }); const client = yield* sessions.issue(relaySessionInput); yield* sessions.markConnected(client.sessionId); yield* TestClock.adjust(Duration.minutes(61)); const changes = yield* Queue.unbounded(); yield* sessions.streamChanges.pipe( Stream.runForEach((change) => Queue.offer(changes, change)), Effect.forkScoped({ startImmediately: true }), ); if (operation === "revoke") { expect(yield* sessions.revoke(client.sessionId)).toBe(true); } else { expect(yield* sessions.revokeAllExcept(administrative.sessionId)).toBe(1); } expect(yield* Queue.take(changes)).toEqual({ type: "clientRemoved", sessionId: client.sessionId, }); const listed = yield* sessions.listActive(); expect(listed).toHaveLength(1); expect(listed[0]?.sessionId).toBe(administrative.sessionId); }).pipe( Effect.scoped, Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer())), ), ); it.effect("records client connection metadata without clearing prior values", () => Effect.gen(function* () { const sessions = yield* SessionStore.SessionStore; const sql = yield* SqlClient.SqlClient; const issued = yield* sessions.issue({ subject: "client-connection-test", method: "bearer-access-token", }); const readRow = sql<{ readonly surface: string | null; readonly appVersion: string | null; }>` SELECT client_surface AS "surface", client_app_version AS "appVersion" FROM auth_sessions WHERE session_id = ${issued.sessionId} `; yield* sessions.recordClientConnection(issued.sessionId, { surface: "mobile", appVersion: "1.2.0", }); expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.2.0" }); // A partial report (old or minimal client) must not null out stored data. yield* sessions.recordClientConnection(issued.sessionId, { appVersion: "1.3.0" }); expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); yield* sessions.recordClientConnection(issued.sessionId, {}); expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), ); });