1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {
3 AuthAdministrativeScopes,
4 AuthStandardClientScopes,
5 EnvironmentId,
6} from "@t3tools/contracts";
7import { expect, it } from "@effect/vitest";
8import * as Duration from "effect/Duration";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Deferred from "effect/Deferred";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Queue from "effect/Queue";
17import * as Redacted from "effect/Redacted";
18import * as Stream from "effect/Stream";
19import * as TestClock from "effect/testing/TestClock";
20import * as SqlClient from "effect/sql/SqlClient";
21
22import * as ServerConfig from "../config.ts";
23import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
24import { PersistenceSqlError } from "../persistence/Errors.ts";
25
26import * as SqlitePersistence from "../persistence/Sqlite.ts";
27import * as AuthSessions from "../persistence/AuthSessions.ts";
28import * as SessionStore from "./SessionStore.ts";
29import * as ServerSecretStore from "./ServerSecretStore.ts";
30import { base64UrlDecodeUtf8, base64UrlEncode, signPayload } from "./utils.ts";
31
32const layerServerConfig = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
33 Layer.effect(
34 ServerConfig.ServerConfig,
35 Effect.gen(function* () {
36 const config = yield* ServerConfig.ServerConfig;
37 return {
38 ...config,
39 ...overrides,
40 } satisfies ServerConfig.ServerConfig["Service"];
41 }),
42 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" })));
43
44const layerServerEnvironment = (environmentId: EnvironmentId) =>
45 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
46 getEnvironmentId: Effect.succeed(environmentId),
47 });
48
49const layerSessionStore = (
50 overrides?: Partial<ServerConfig.ServerConfig["Service"]>,
51 environmentId = EnvironmentId.make("test-environment"),
52) =>
53 SessionStore.layer.pipe(
54 Layer.provide(SqlitePersistence.layerMemory),
55 Layer.provide(ServerSecretStore.layer),
56 Layer.provide(layerServerEnvironment(environmentId)),
57 Layer.provide(layerServerConfig(overrides)),
58 );
59
60const relaySessionInput = {
61 subject: "managed-relay-bootstrap",
62 method: "dpop-access-token",
63 proofKeyThumbprint: "relay-proof-key",
64 ttl: Duration.hours(1),
65 client: { label: "Relay desktop", deviceType: "desktop" },
66} as const;
67
68const makeDiskSessionStoreLayer = Effect.fn("makeDiskSessionStoreLayer")(function* (
69 baseDir: string,
70 token?: string,
71) {
72 const devUrl = new URL("http://127.0.0.1:5173");
73 const paths = yield* ServerConfig.deriveServerPaths(baseDir, devUrl, {
74 baseDirIsExplicit: true,
75 });
76 yield* ServerConfig.ensureServerDirectories(paths);
77 const layerPersistence = SqlitePersistence.layerFromPath(paths.dbPath);
78 return SessionStore.layer.pipe(
79 Layer.provide(layerPersistence),
80 Layer.provide(ServerSecretStore.layer),
81 Layer.provide(layerServerEnvironment(EnvironmentId.make(baseDir))),
82 Layer.provide(
83 layerServerConfig({
84 ...paths,
85 baseDir,
86 mode: "web",
87 devUrl,
88 ...(token === undefined ? {} : { devAuthToken: Redacted.make(token) }),
89 }),
90 ),
91 );
92});
93
94const repositoryFailure = new PersistenceSqlError({
95 operation: "AuthSessionRepository.getById:query",
96 detail: "sqlite is unavailable",
97});
98
99const layerFailingSessionLookupRepository = Layer.succeed(AuthSessions.AuthSessionRepository, {
100 create: () => Effect.void,
101 createReplacingActive: () => Effect.succeed([]),
102 createIfAbsent: () => Effect.void,
103 getById: () => Effect.fail(repositoryFailure),
104 listActive: () => Effect.succeed([]),
105 revoke: () => Effect.fail(repositoryFailure),
106 revokeAllExcept: () => Effect.fail(repositoryFailure),
107 setLastConnectedAt: () => Effect.void,
108 setClientConnection: () => Effect.void,
109});
110
111const layerFailingSessionLookupCredential = Layer.effect(
112 SessionStore.SessionStore,
113 SessionStore.make,
114).pipe(
115 Layer.provide(layerFailingSessionLookupRepository),
116 Layer.provide(ServerSecretStore.layer),
117 Layer.provide(SqlitePersistence.layerMemory),
118 Layer.provide(layerServerEnvironment(EnvironmentId.make("test-environment"))),
119 Layer.provide(layerServerConfig()),
120);
121
122it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
123 it.effect.each(["revoke", "revokeAllExcept", "replace"] as const)(
124 "invalidates every connection for a session on %s and leaves other sessions active",
125 (operation) =>
126 Effect.gen(function* () {
127 const sessions = yield* SessionStore.SessionStore;
128 const owner = yield* sessions.issue({ subject: "owner" });
129 const client = yield* sessions.issue(relaySessionInput);
130 const ownerInvalidated = yield* Deferred.make<void>();
131 yield* sessions
132 .awaitInvalidation(owner.sessionId)
133 .pipe(
134 Effect.andThen(Deferred.succeed(ownerInvalidated, undefined)),
135 Effect.forkScoped({ startImmediately: true }),
136 );
137 const first = yield* sessions
138 .awaitInvalidation(client.sessionId)
139 .pipe(Effect.forkScoped({ startImmediately: true }));
140 const second = yield* sessions
141 .awaitInvalidation(client.sessionId)
142 .pipe(Effect.forkScoped({ startImmediately: true }));
143 if (operation === "revoke") yield* sessions.revoke(client.sessionId);
144 else if (operation === "revokeAllExcept") yield* sessions.revokeAllExcept(owner.sessionId);
145 else yield* sessions.issue({ ...relaySessionInput, replaceSessionId: client.sessionId });
146 yield* Fiber.join(first);
147 yield* Fiber.join(second);
148 expect(yield* Deferred.isDone(ownerInvalidated)).toBe(false);
149 // A connection whose upgrade authenticated just before revocation must also stop.
150 yield* sessions.awaitInvalidation(client.sessionId);
151 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
152 );
153
154 it.effect("expires a connected session at its credential deadline", () =>
155 Effect.gen(function* () {
156 const sessions = yield* SessionStore.SessionStore;
157 const client = yield* sessions.issue(relaySessionInput);
158 yield* sessions.markConnected(client.sessionId);
159 const invalidated = yield* Deferred.make<void>();
160 const watcher = yield* sessions
161 .awaitInvalidation(client.sessionId)
162 .pipe(
163 Effect.andThen(Deferred.succeed(invalidated, undefined)),
164 Effect.forkScoped({ startImmediately: true }),
165 );
166 yield* TestClock.adjust(Duration.minutes(59));
167 expect(yield* Deferred.isDone(invalidated)).toBe(false);
168 yield* TestClock.adjust(Duration.minutes(1));
169 yield* Fiber.join(watcher);
170 yield* sessions.awaitInvalidation(client.sessionId);
171 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
172 );
173
174 it.effect("keys remote cookies by environment identity instead of state directory", () =>
175 Effect.gen(function* () {
176 const cookieName = (stateDir: string, environmentId: EnvironmentId) =>
177 Effect.gen(function* () {
178 const sessions = yield* SessionStore.SessionStore;
179 return sessions.cookieName;
180 }).pipe(
181 Effect.provide(
182 layerSessionStore({ mode: "web", host: "192.168.1.50", stateDir }, environmentId),
183 ),
184 );
185
186 const original = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-one"));
187 const moved = yield* cookieName("/srv/t3-moved", EnvironmentId.make("environment-one"));
188 const other = yield* cookieName("/srv/t3-one", EnvironmentId.make("environment-two"));
189
190 expect(moved).toBe(original);
191 expect(other).not.toBe(original);
192 }),
193 );
194
195 it.effect("keeps reusable dev auth local across disk-backed stores and restarts", () =>
196 Effect.gen(function* () {
197 const fs = yield* FileSystem.FileSystem;
198 const token = "reusable-dev-auth-token-that-is-long-enough";
199 const baseA = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-a-" });
200 const baseB = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-b-" });
201 const layerA = yield* makeDiskSessionStoreLayer(baseA, token);
202 const fromA = yield* Effect.gen(function* () {
203 const sessions = yield* SessionStore.SessionStore;
204 const dev = yield* sessions.verify(token);
205 const local = yield* sessions.issue({ subject: "environment-a" });
206 const ticket = yield* sessions.issueWebSocketToken(local.sessionId);
207 yield* sessions.revoke(dev.sessionId);
208 return { dev, local, ticket };
209 }).pipe(Effect.provide(layerA), Effect.scoped);
210
211 const layerB = yield* makeDiskSessionStoreLayer(baseB, token);
212 yield* Effect.gen(function* () {
213 const sessions = yield* SessionStore.SessionStore;
214 const dev = yield* sessions.verify(token);
215 expect(dev.sessionId).toBe(fromA.dev.sessionId);
216 expect((yield* Effect.flip(sessions.verify(fromA.local.token)))._tag).toBe(
217 "InvalidSessionTokenSignatureError",
218 );
219 expect((yield* Effect.flip(sessions.verifyWebSocketToken(fromA.ticket.token)))._tag).toBe(
220 "InvalidWebSocketTokenSignatureError",
221 );
222 }).pipe(Effect.provide(layerB), Effect.scoped);
223
224 const layerReopenedB = yield* makeDiskSessionStoreLayer(baseB, token);
225 yield* Effect.gen(function* () {
226 const sessions = yield* SessionStore.SessionStore;
227 const dev = yield* sessions.verify(token);
228 expect(dev.sessionId).toBe(fromA.dev.sessionId);
229 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
230 expect((yield* sessions.verifyWebSocketToken(ticket.token)).sessionId).toBe(dev.sessionId);
231 }).pipe(Effect.provide(layerReopenedB), Effect.scoped);
232
233 const layerReopenedA = yield* makeDiskSessionStoreLayer(baseA, token);
234 yield* Effect.gen(function* () {
235 const sessions = yield* SessionStore.SessionStore;
236 expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");
237 }).pipe(Effect.provide(layerReopenedA), Effect.scoped);
238 }),
239 );
240
241 it.effect("invalidates old dev credentials and tickets after rotation or removal", () =>
242 Effect.gen(function* () {
243 const fs = yield* FileSystem.FileSystem;
244 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-dev-auth-rotation-" });
245 const oldToken = "old-reusable-dev-auth-token-that-is-long-enough";
246 const newToken = "new-reusable-dev-auth-token-that-is-long-enough";
247 const layerInitial = yield* makeDiskSessionStoreLayer(baseDir, oldToken);
248 const old = yield* Effect.gen(function* () {
249 const sessions = yield* SessionStore.SessionStore;
250 const dev = yield* sessions.verify(oldToken);
251 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
252 return { dev, ticket };
253 }).pipe(Effect.provide(layerInitial), Effect.scoped);
254
255 const layerRotated = yield* makeDiskSessionStoreLayer(baseDir, newToken);
256 const rotated = yield* Effect.gen(function* () {
257 const sessions = yield* SessionStore.SessionStore;
258 expect((yield* Effect.flip(sessions.verify(oldToken)))._tag).toBe(
259 "MalformedSessionTokenError",
260 );
261 expect((yield* Effect.flip(sessions.verifyWebSocketToken(old.ticket.token)))._tag).toBe(
262 "UnknownWebSocketSessionError",
263 );
264 expect(
265 (yield* sessions.listActive()).some((row) => row.sessionId === old.dev.sessionId),
266 ).toBe(true);
267 const dev = yield* sessions.verify(newToken);
268 const ticket = yield* sessions.issueWebSocketToken(dev.sessionId);
269 return { dev, ticket };
270 }).pipe(Effect.provide(layerRotated), Effect.scoped);
271
272 const layerRemoved = yield* makeDiskSessionStoreLayer(baseDir);
273 yield* Effect.gen(function* () {
274 const sessions = yield* SessionStore.SessionStore;
275 expect((yield* Effect.flip(sessions.verify(newToken)))._tag).toBe(
276 "MalformedSessionTokenError",
277 );
278 expect((yield* Effect.flip(sessions.verifyWebSocketToken(rotated.ticket.token)))._tag).toBe(
279 "UnknownWebSocketSessionError",
280 );
281 expect(
282 (yield* sessions.listActive()).some((row) => row.sessionId === rotated.dev.sessionId),
283 ).toBe(true);
284 }).pipe(Effect.provide(layerRemoved), Effect.scoped);
285 }),
286 );
287
288 it.effect("keeps the reusable token active after normal sessions expire", () =>
289 Effect.gen(function* () {
290 const sessions = yield* SessionStore.SessionStore;
291 const token = "reusable-dev-auth-token-that-is-long-enough";
292 const normal = yield* sessions.issue({ subject: "normal-session" });
293
294 yield* TestClock.adjust(Duration.days(31));
295
296 expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token");
297 expect((yield* Effect.flip(sessions.verify(normal.token)))._tag).toBe(
298 "SessionTokenExpiredError",
299 );
300 }).pipe(
301 Effect.provide(
302 Layer.merge(
303 layerSessionStore({
304 mode: "web",
305 devUrl: new URL("http://127.0.0.1:5173"),
306 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
307 }),
308 TestClock.layer(),
309 ),
310 ),
311 ),
312 );
313
314 it.effect("issues and verifies signed browser session tokens", () =>
315 Effect.gen(function* () {
316 const sessions = yield* SessionStore.SessionStore;
317 const issued = yield* sessions.issue({
318 subject: "desktop-bootstrap",
319 scopes: ["orchestration:read", "access:write"],
320 client: {
321 label: "Desktop app",
322 deviceType: "desktop",
323 os: "macOS",
324 browser: "Electron",
325 ipAddress: "127.0.0.1",
326 },
327 });
328 const verified = yield* sessions.verify(issued.token);
329
330 expect(verified.method).toBe("browser-session-cookie");
331 expect(verified.subject).toBe("desktop-bootstrap");
332 expect(verified.scopes).toEqual(["orchestration:read", "access:write"]);
333 expect(verified.client.label).toBe("Desktop app");
334 expect(verified.client.browser).toBe("Electron");
335 expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString());
336 }).pipe(Effect.provide(layerSessionStore())),
337 );
338 it.effect("carries a runtime-mode ceiling only on sessions issued with one", () =>
339 Effect.gen(function* () {
340 const sessions = yield* SessionStore.SessionStore;
341 const capped = yield* sessions.issue({
342 subject: "mcp-client",
343 method: "bearer-access-token",
344 runtimeModeCeiling: "auto",
345 });
346 const uncapped = yield* sessions.issue({ method: "bearer-access-token" });
347
348 expect((yield* sessions.verify(capped.token)).runtimeModeCeiling).toBe("auto");
349 expect((yield* sessions.verify(uncapped.token)).runtimeModeCeiling).toBeUndefined();
350 }).pipe(Effect.provide(layerSessionStore())),
351 );
352 it.effect("keeps recorded scopes unchanged for both token versions", () =>
353 Effect.gen(function* () {
354 const sessions = yield* SessionStore.SessionStore;
355 const secrets = yield* ServerSecretStore.ServerSecretStore;
356 const legacyScopes = ["orchestration:read", "terminal:operate", "review:write"] as const;
357 const issued = yield* sessions.issue({ subject: "one-time-token", scopes: legacyScopes });
358 // Accept prerelease v2 credentials without widening their recorded grant.
359 const [encodedPayload] = issued.token.split(".");
360 const currentClaims = base64UrlDecodeUtf8(encodedPayload!);
361 expect(currentClaims).toContain('"v":1');
362 const legacyPayload = base64UrlEncode(currentClaims.replace('"v":1', '"v":2'));
363 const secret = yield* secrets.getOrCreateRandom("server-signing-key", 32);
364 const legacyToken = `${legacyPayload}.${signPayload(legacyPayload, secret)}`;
365
366 expect((yield* sessions.verify(issued.token)).scopes).toEqual(legacyScopes);
367 expect((yield* sessions.verify(legacyToken)).scopes).toEqual(legacyScopes);
368 }).pipe(
369 Effect.provide(
370 SessionStore.layer.pipe(
371 Layer.provideMerge(ServerSecretStore.layer),
372 Layer.provide(SqlitePersistence.layerMemory),
373 Layer.provide(layerServerEnvironment(EnvironmentId.make("test-environment"))),
374 Layer.provide(layerServerConfig()),
375 ),
376 ),
377 ),
378 );
379
380 it.effect("rejects malformed session tokens", () =>
381 Effect.gen(function* () {
382 const sessions = yield* SessionStore.SessionStore;
383 const error = yield* Effect.flip(sessions.verify("not-a-session-token"));
384
385 expect(error._tag).toBe("MalformedSessionTokenError");
386 expect(error.message).toContain("Malformed session token");
387 }).pipe(Effect.provide(layerSessionStore())),
388 );
389 it.effect("preserves repository failures while verifying session and websocket credentials", () =>
390 Effect.gen(function* () {
391 const sessions = yield* SessionStore.SessionStore;
392 const issued = yield* sessions.issue({
393 method: "bearer-access-token",
394 subject: "repository-failure",
395 });
396 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);
397
398 const sessionError = yield* Effect.flip(sessions.verify(issued.token));
399 const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
400 const revokeError = yield* Effect.flip(sessions.revoke(issued.sessionId));
401 const revokeOthersError = yield* Effect.flip(sessions.revokeAllExcept(issued.sessionId));
402
403 expect(sessionError._tag).toBe("SessionCredentialVerificationError");
404 expect(websocketError._tag).toBe("WebSocketTokenVerificationError");
405 expect(sessionError.cause).toBe(repositoryFailure);
406 expect(websocketError.cause).toBe(repositoryFailure);
407 if (sessionError._tag === "SessionCredentialVerificationError") {
408 expect(sessionError.sessionId).toBe(issued.sessionId);
409 }
410 if (websocketError._tag === "WebSocketTokenVerificationError") {
411 expect(websocketError.sessionId).toBe(issued.sessionId);
412 }
413 expect(revokeError).toMatchObject({
414 _tag: "SessionRevocationError",
415 sessionId: issued.sessionId,
416 cause: repositoryFailure,
417 });
418 expect(revokeOthersError).toMatchObject({
419 _tag: "OtherSessionsRevocationError",
420 currentSessionId: issued.sessionId,
421 cause: repositoryFailure,
422 });
423 }).pipe(Effect.provide(layerFailingSessionLookupCredential)),
424 );
425 it.effect("verifies session tokens against the Effect clock", () =>
426 Effect.gen(function* () {
427 const sessions = yield* SessionStore.SessionStore;
428 const issued = yield* sessions.issue({
429 method: "bearer-access-token",
430 subject: "test-clock",
431 });
432 const verified = yield* sessions.verify(issued.token);
433
434 expect(verified.method).toBe("bearer-access-token");
435 expect(verified.subject).toBe("test-clock");
436 expect(verified.scopes).toEqual(AuthStandardClientScopes);
437 }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
438 );
439
440 it.effect("atomically replaces active sessions with the same subject and method", () =>
441 Effect.gen(function* () {
442 const sessions = yield* SessionStore.SessionStore;
443 const browser = yield* sessions.issue({
444 subject: "desktop-bootstrap",
445 method: "browser-session-cookie",
446 });
447 const [firstBearer, secondBearer] = yield* Effect.all(
448 [
449 sessions.issue({
450 subject: "desktop-bootstrap",
451 method: "bearer-access-token",
452 replaceActiveForSubjectAndMethod: true,
453 }),
454 sessions.issue({
455 subject: "desktop-bootstrap",
456 method: "bearer-access-token",
457 replaceActiveForSubjectAndMethod: true,
458 }),
459 ],
460 { concurrency: "unbounded" },
461 );
462
463 const active = yield* sessions.listActive();
464 const bearerVerification = yield* Effect.all([
465 sessions.verify(firstBearer.token).pipe(Effect.option),
466 sessions.verify(secondBearer.token).pipe(Effect.option),
467 ]);
468
469 expect(active).toHaveLength(2);
470 expect(active.find((entry) => entry.sessionId === browser.sessionId)).toBeDefined();
471 expect(
472 active.filter(
473 (entry) =>
474 entry.subject === "desktop-bootstrap" && entry.method === "bearer-access-token",
475 ),
476 ).toHaveLength(1);
477 expect(bearerVerification.filter(Option.isSome)).toHaveLength(1);
478 }).pipe(Effect.provide(layerSessionStore())),
479 );
480
481 it.effect("keeps the previous desktop session valid when replacement fails", () =>
482 Effect.gen(function* () {
483 const sessions = yield* SessionStore.SessionStore;
484 const sql = yield* SqlClient.SqlClient;
485 const previous = yield* sessions.issue({
486 subject: "desktop-bootstrap",
487 method: "bearer-access-token",
488 });
489 yield* sql`
490 CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions
491 BEGIN
492 SELECT RAISE(ABORT, 'simulated insert failure');
493 END
494 `;
495
496 const error = yield* sessions
497 .issue({
498 subject: "desktop-bootstrap",
499 method: "bearer-access-token",
500 replaceActiveForSubjectAndMethod: true,
501 })
502 .pipe(Effect.flip);
503
504 expect(error._tag).toBe("SessionCredentialIssueError");
505 expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId);
506 expect((yield* sessions.listActive()).map((session) => session.sessionId)).toEqual([
507 previous.sessionId,
508 ]);
509 }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))),
510 );
511
512 it.effect.each(["insert", "revoke"] as const)(
513 "keeps existing browser sessions valid when replacement cannot %s",
514 (operation) =>
515 Effect.gen(function* () {
516 const sessions = yield* SessionStore.SessionStore;
517 const sql = yield* SqlClient.SqlClient;
518 const previous = yield* sessions.issue({ subject: "one-time-token" });
519 const unrelated = yield* sessions.issue({ subject: "one-time-token" });
520 if (operation === "insert") {
521 yield* sql`
522 CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions
523 BEGIN
524 SELECT RAISE(ABORT, 'simulated insert failure');
525 END
526 `;
527 } else {
528 yield* sql`
529 CREATE TRIGGER reject_auth_session_revocation BEFORE UPDATE OF revoked_at ON auth_sessions
530 BEGIN
531 SELECT RAISE(ABORT, 'simulated revocation failure');
532 END
533 `;
534 }
535
536 const error = yield* sessions
537 .issue({
538 subject: "replacement-pairing",
539 scopes: ["orchestration:read"],
540 replaceSessionId: previous.sessionId,
541 })
542 .pipe(Effect.flip);
543
544 expect(error._tag).toBe("SessionCredentialIssueError");
545 expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId);
546 expect((yield* sessions.verify(unrelated.token)).sessionId).toBe(unrelated.sessionId);
547 expect((yield* sessions.listActive()).map((session) => session.sessionId).sort()).toEqual(
548 [previous.sessionId, unrelated.sessionId].sort(),
549 );
550 }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))),
551 );
552
553 it.effect("rejects websocket tokens once the parent session has expired", () =>
554 Effect.gen(function* () {
555 const sessions = yield* SessionStore.SessionStore;
556 const issued = yield* sessions.issue({
557 method: "bearer-access-token",
558 subject: "short-lived",
559 ttl: Duration.seconds(1),
560 });
561 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);
562
563 yield* TestClock.adjust(Duration.seconds(2));
564
565 const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
566 expect(error._tag).toBe("WebSocketSessionExpiredError");
567 if (error._tag === "WebSocketSessionExpiredError") {
568 expect(error.sessionId).toBe(issued.sessionId);
569 expect(error.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds);
570 expect(error.observedAt.epochMilliseconds).toBeGreaterThan(
571 error.expiresAt.epochMilliseconds,
572 );
573 }
574 }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
575 );
576
577 it.effect("includes expiry context when session and websocket tokens expire", () =>
578 Effect.gen(function* () {
579 const sessions = yield* SessionStore.SessionStore;
580 const issued = yield* sessions.issue({
581 method: "bearer-access-token",
582 subject: "short-lived-token",
583 ttl: Duration.seconds(1),
584 });
585 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId, {
586 ttl: Duration.seconds(1),
587 });
588
589 yield* TestClock.adjust(Duration.seconds(2));
590
591 const sessionError = yield* Effect.flip(sessions.verify(issued.token));
592 const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
593
594 expect(sessionError._tag).toBe("SessionTokenExpiredError");
595 if (sessionError._tag === "SessionTokenExpiredError") {
596 expect(sessionError.sessionId).toBe(issued.sessionId);
597 expect(sessionError.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds);
598 expect(sessionError.observedAt.epochMilliseconds).toBeGreaterThan(
599 sessionError.expiresAt.epochMilliseconds,
600 );
601 }
602 expect(websocketError._tag).toBe("WebSocketTokenExpiredError");
603 if (websocketError._tag === "WebSocketTokenExpiredError") {
604 expect(websocketError.sessionId).toBe(issued.sessionId);
605 expect(websocketError.expiresAt.epochMilliseconds).toBe(
606 websocket.expiresAt.epochMilliseconds,
607 );
608 expect(websocketError.observedAt.epochMilliseconds).toBeGreaterThan(
609 websocketError.expiresAt.epochMilliseconds,
610 );
611 }
612 }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
613 );
614
615 it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () =>
616 Effect.gen(function* () {
617 const sessions = yield* SessionStore.SessionStore;
618 const administrative = yield* sessions.issue({
619 subject: "desktop-bootstrap",
620 scopes: ["orchestration:read", "access:write"],
621 client: {
622 label: "Desktop app",
623 deviceType: "desktop",
624 os: "macOS",
625 browser: "Electron",
626 },
627 });
628 const client = yield* sessions.issue({
629 subject: "one-time-token",
630 scopes: ["orchestration:read"],
631 client: {
632 label: "Julius iPhone",
633 deviceType: "mobile",
634 os: "iOS",
635 browser: "Safari",
636 ipAddress: "192.168.1.88",
637 },
638 });
639 const clientWebSocket = yield* sessions.issueWebSocketToken(client.sessionId);
640
641 yield* sessions.markConnected(client.sessionId);
642 const beforeRevoke = yield* sessions.listActive();
643 const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId);
644 const afterRevoke = yield* sessions.listActive();
645 const revokedClient = yield* Effect.flip(sessions.verify(client.token));
646 const revokedClientWebSocket = yield* Effect.flip(
647 sessions.verifyWebSocketToken(clientWebSocket.token),
648 );
649
650 expect(beforeRevoke).toHaveLength(2);
651 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(
652 true,
653 );
654 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe(
655 "Julius iPhone",
656 );
657 expect(
658 beforeRevoke.find((entry) => entry.sessionId === administrative.sessionId)?.client
659 .deviceType,
660 ).toBe("desktop");
661 expect(revokedCount).toBe(1);
662 expect(afterRevoke).toHaveLength(1);
663 expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId);
664 expect(revokedClient._tag).toBe("SessionTokenRevokedError");
665 if (revokedClient._tag === "SessionTokenRevokedError") {
666 expect(revokedClient.sessionId).toBe(client.sessionId);
667 expect(revokedClient.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0);
668 }
669 expect(revokedClientWebSocket._tag).toBe("WebSocketSessionRevokedError");
670 if (revokedClientWebSocket._tag === "WebSocketSessionRevokedError") {
671 expect(revokedClientWebSocket.sessionId).toBe(client.sessionId);
672 expect(revokedClientWebSocket.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0);
673 }
674 }).pipe(Effect.provide(layerSessionStore())),
675 );
676
677 it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () =>
678 Effect.gen(function* () {
679 const sessions = yield* SessionStore.SessionStore;
680 const issued = yield* sessions.issue({
681 subject: "reconnect-test",
682 method: "bearer-access-token",
683 });
684
685 const beforeConnect = yield* sessions.listActive();
686 expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
687
688 yield* TestClock.adjust(Duration.seconds(1));
689 yield* sessions.markConnected(issued.sessionId);
690 const firstConnect = yield* sessions.listActive();
691 const firstConnectedAt = firstConnect[0]?.lastConnectedAt;
692
693 expect(firstConnect[0]?.connected).toBe(true);
694 expect(firstConnectedAt).not.toBeNull();
695
696 yield* TestClock.adjust(Duration.seconds(1));
697 yield* sessions.markConnected(issued.sessionId);
698 const stillConnected = yield* sessions.listActive();
699
700 expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
701
702 yield* sessions.markDisconnected(issued.sessionId);
703 yield* sessions.markDisconnected(issued.sessionId);
704 const afterDisconnect = yield* sessions.listActive();
705
706 expect(afterDisconnect[0]?.connected).toBe(false);
707 expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
708
709 yield* TestClock.adjust(Duration.seconds(1));
710 yield* sessions.markConnected(issued.sessionId);
711 const afterReconnect = yield* sessions.listActive();
712
713 expect(afterReconnect[0]?.connected).toBe(true);
714 expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull();
715 expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());
716 }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
717 );
718
719 it.effect("keeps connected relay sessions visible through expiry and HTTP renewal", () =>
720 Effect.gen(function* () {
721 const sessions = yield* SessionStore.SessionStore;
722 const original = yield* sessions.issue(relaySessionInput);
723 const websocket = yield* sessions.issueWebSocketToken(original.sessionId, {
724 ttl: Duration.hours(2),
725 });
726 yield* sessions.verifyWebSocketToken(websocket.token);
727 yield* sessions.markConnected(original.sessionId);
728 const beforeExpiry = yield* sessions.listActive();
729 expect(beforeExpiry).toHaveLength(1);
730 expect(beforeExpiry[0]?.connected).toBe(true);
731
732 yield* TestClock.adjust(Duration.minutes(61));
733
734 expect(yield* sessions.listActive()).toEqual(beforeExpiry);
735 expect(yield* Effect.flip(sessions.verify(original.token))).toMatchObject({
736 _tag: "SessionTokenExpiredError",
737 });
738 expect(yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token))).toMatchObject({
739 _tag: "WebSocketSessionExpiredError",
740 });
741
742 const renewed = yield* sessions.issue(relaySessionInput);
743 const afterRenewal = yield* sessions.listActive();
744 expect(renewed.sessionId).not.toBe(original.sessionId);
745 expect(afterRenewal).toHaveLength(2);
746 expect(afterRenewal).toEqual(
747 expect.arrayContaining([
748 beforeExpiry[0],
749 expect.objectContaining({
750 sessionId: renewed.sessionId,
751 connected: false,
752 lastConnectedAt: null,
753 }),
754 ]),
755 );
756 }).pipe(Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
757 );
758
759 it.effect.each([1, 2])(
760 "removes an expired session from listings and updates after its last of %s sockets closes",
761 (socketCount) =>
762 Effect.gen(function* () {
763 const sessions = yield* SessionStore.SessionStore;
764 const issued = yield* sessions.issue(relaySessionInput);
765 const changes = yield* Queue.unbounded<SessionStore.SessionCredentialChange>();
766 yield* sessions.streamChanges.pipe(
767 Stream.runForEach((change) => Queue.offer(changes, change)),
768 Effect.forkScoped({ startImmediately: true }),
769 );
770 for (let index = 0; index < socketCount; index += 1) {
771 yield* sessions.markConnected(issued.sessionId);
772 expect(yield* Queue.take(changes)).toMatchObject({
773 type: "clientUpserted",
774 clientSession: { sessionId: issued.sessionId, connected: true },
775 });
776 }
777
778 yield* TestClock.adjust(Duration.minutes(61));
779
780 for (let remaining = socketCount - 1; remaining >= 0; remaining -= 1) {
781 yield* sessions.markDisconnected(issued.sessionId);
782 const change = yield* Queue.take(changes);
783 const listed = yield* sessions.listActive();
784 if (remaining > 0) {
785 expect(change).toMatchObject({
786 type: "clientUpserted",
787 clientSession: { sessionId: issued.sessionId, connected: true },
788 });
789 expect(listed).toHaveLength(1);
790 expect(listed[0]?.connected).toBe(true);
791 } else {
792 expect(change).toEqual({ type: "clientRemoved", sessionId: issued.sessionId });
793 expect(listed).toEqual([]);
794 }
795 }
796 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
797 );
798
799 it.effect.each(["revoke", "revokeAllExcept"] as const)(
800 "removes expired connected sessions with %s",
801 (operation) =>
802 Effect.gen(function* () {
803 const sessions = yield* SessionStore.SessionStore;
804 const administrative = yield* sessions.issue({ subject: "desktop-bootstrap" });
805 const client = yield* sessions.issue(relaySessionInput);
806 yield* sessions.markConnected(client.sessionId);
807 yield* TestClock.adjust(Duration.minutes(61));
808 const changes = yield* Queue.unbounded<SessionStore.SessionCredentialChange>();
809 yield* sessions.streamChanges.pipe(
810 Stream.runForEach((change) => Queue.offer(changes, change)),
811 Effect.forkScoped({ startImmediately: true }),
812 );
813
814 if (operation === "revoke") {
815 expect(yield* sessions.revoke(client.sessionId)).toBe(true);
816 } else {
817 expect(yield* sessions.revokeAllExcept(administrative.sessionId)).toBe(1);
818 }
819
820 expect(yield* Queue.take(changes)).toEqual({
821 type: "clientRemoved",
822 sessionId: client.sessionId,
823 });
824 const listed = yield* sessions.listActive();
825 expect(listed).toHaveLength(1);
826 expect(listed[0]?.sessionId).toBe(administrative.sessionId);
827 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerSessionStore(), TestClock.layer()))),
828 );
829
830 it.effect("records client connection metadata without clearing prior values", () =>
831 Effect.gen(function* () {
832 const sessions = yield* SessionStore.SessionStore;
833 const sql = yield* SqlClient.SqlClient;
834 const issued = yield* sessions.issue({
835 subject: "client-connection-test",
836 method: "bearer-access-token",
837 });
838 const readRow = sql<{
839 readonly surface: string | null;
840 readonly appVersion: string | null;
841 }>`
842 SELECT client_surface AS "surface", client_app_version AS "appVersion"
843 FROM auth_sessions
844 WHERE session_id = ${issued.sessionId}
845 `;
846
847 yield* sessions.recordClientConnection(issued.sessionId, {
848 surface: "mobile",
849 appVersion: "1.2.0",
850 });
851 expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.2.0" });
852
853 // A partial report (old or minimal client) must not null out stored data.
854 yield* sessions.recordClientConnection(issued.sessionId, { appVersion: "1.3.0" });
855 expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" });
856
857 yield* sessions.recordClientConnection(issued.sessionId, {});
858 expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" });
859 }).pipe(Effect.provide(Layer.mergeAll(layerSessionStore(), SqlitePersistence.layerMemory))),
860 );
861 it.effect("binds delegated credentials and active sockets to the machine grant", () =>
862 Effect.gen(function* () {
863 const sessions = yield* SessionStore.SessionStore;
864 const parent = yield* sessions.issue({
865 scopes: AuthAdministrativeScopes,
866 method: "bearer-access-token",
867 });
868 const child = yield* sessions.issue({
869 parentSessionId: parent.sessionId,
870 scopes: ["orchestration:read"],
871 method: "bearer-access-token",
872 });
873 const verified = yield* sessions.verify(child.token);
874 expect(verified.parentSessionId).toBe(parent.sessionId);
875 const ticket = yield* sessions.issueWebSocketToken(child.sessionId);
876 expect((yield* sessions.verifyWebSocketToken(ticket.token)).parentSessionId).toBe(
877 parent.sessionId,
878 );
879 const invalidation = yield* sessions
880 .awaitInvalidation(child.sessionId)
881 .pipe(Effect.forkScoped({ startImmediately: true }));
882 yield* sessions.revoke(parent.sessionId);
883 yield* Fiber.join(invalidation);
884 expect((yield* sessions.verify(child.token).pipe(Effect.flip))._tag).toBe(
885 "SessionTokenRevokedError",
886 );
887 expect((yield* sessions.verifyWebSocketToken(ticket.token).pipe(Effect.flip))._tag).toBe(
888 "WebSocketSessionRevokedError",
889 );
890 expect(yield* sessions.listActive()).toEqual([]);
891 yield* sessions.awaitInvalidation(child.sessionId);
892 }).pipe(Effect.scoped, Effect.provide(layerSessionStore())),
893 );
894
895 it.effect("caps delegated session expiry and rejects nested or expanded grants", () =>
896 Effect.gen(function* () {
897 const sessions = yield* SessionStore.SessionStore;
898 const parent = yield* sessions.issue({
899 scopes: ["access:write", "orchestration:read"],
900 ttl: Duration.seconds(1),
901 });
902 const child = yield* sessions.issue({
903 parentSessionId: parent.sessionId,
904 scopes: ["orchestration:read"],
905 ttl: Duration.hours(1),
906 });
907 expect(child.expiresAt.epochMilliseconds).toBe(parent.expiresAt.epochMilliseconds);
908 expect(
909 (yield* sessions
910 .issue({ parentSessionId: child.sessionId, scopes: ["orchestration:read"] })
911 .pipe(Effect.flip))._tag,
912 ).toBe("SessionCredentialIssueError");
913 expect(
914 (yield* sessions
915 .issue({ parentSessionId: parent.sessionId, scopes: ["orchestration:operate"] })
916 .pipe(Effect.flip))._tag,
917 ).toBe("SessionCredentialIssueError");
918 const invalidation = yield* sessions
919 .awaitInvalidation(child.sessionId)
920 .pipe(Effect.forkScoped({ startImmediately: true }));
921 yield* TestClock.adjust(Duration.seconds(1));
922 yield* Fiber.join(invalidation);
923 expect((yield* sessions.verify(child.token).pipe(Effect.flip))._tag).toBe(
924 "SessionTokenExpiredError",
925 );
926 expect(yield* sessions.listActive()).toEqual([]);
927 }).pipe(Effect.scoped, Effect.provide(layerSessionStore())),
928 );
929 it.effect("closes a delegated socket when the CLI revokes its parent in the database", () =>
930 Effect.gen(function* () {
931 const sessions = yield* SessionStore.SessionStore;
932 const repository = yield* AuthSessions.AuthSessionRepository;
933 const parent = yield* sessions.issue({ scopes: AuthAdministrativeScopes });
934 const child = yield* sessions.issue({
935 parentSessionId: parent.sessionId,
936 scopes: ["orchestration:read"],
937 });
938 const invalidated = yield* Deferred.make<void>();
939 yield* sessions
940 .awaitInvalidation(child.sessionId)
941 .pipe(
942 Effect.andThen(Deferred.succeed(invalidated, undefined)),
943 Effect.forkScoped({ startImmediately: true }),
944 );
945 expect(yield* Deferred.isDone(invalidated)).toBe(false);
946 yield* repository.revoke({ sessionId: parent.sessionId, revokedAt: yield* DateTime.now });
947 yield* TestClock.adjust(Duration.seconds(5));
948 yield* Deferred.await(invalidated);
949 expect((yield* sessions.verify(child.token).pipe(Effect.flip))._tag).toBe(
950 "SessionTokenRevokedError",
951 );
952 }).pipe(
953 Effect.scoped,
954 Effect.provide(layerSessionStore().pipe(Layer.provideMerge(SqlitePersistence.layerMemory))),
955 ),
956 );
957});