| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { |
| 3 | AuthAdministrativeScopes, |
| 4 | AuthStandardClientScopes, |
| 5 | EnvironmentId, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import { expect, it } from "@effect/vitest"; |
| 8 | import * as Duration from "effect/Duration"; |
| 9 | import * as DateTime from "effect/DateTime"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Fiber from "effect/Fiber"; |
| 12 | import * as Deferred from "effect/Deferred"; |
| 13 | import * as FileSystem from "effect/FileSystem"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; |
| 16 | import * as Queue from "effect/Queue"; |
| 17 | import * as Redacted from "effect/Redacted"; |
| 18 | import * as Stream from "effect/Stream"; |
| 19 | import * as TestClock from "effect/testing/TestClock"; |
| 20 | import * as SqlClient from "effect/sql/SqlClient"; |
| 21 | |
| 22 | import * as ServerConfig from "../config.ts"; |
| 23 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 24 | import { PersistenceSqlError } from "../persistence/Errors.ts"; |
| 25 | |
| 26 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 27 | import * as AuthSessions from "../persistence/AuthSessions.ts"; |
| 28 | import * as SessionStore from "./SessionStore.ts"; |
| 29 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 30 | import { base64UrlDecodeUtf8, base64UrlEncode, signPayload } from "./utils.ts"; |
| 31 | |
| 32 | const 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 | |
| 44 | const layerServerEnvironment = (environmentId: EnvironmentId) => |
| 45 | Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { |
| 46 | getEnvironmentId: Effect.succeed(environmentId), |
| 47 | }); |
| 48 | |
| 49 | const 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 | |
| 60 | const 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 | |
| 68 | const 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 | |
| 94 | const repositoryFailure = new PersistenceSqlError({ |
| 95 | operation: "AuthSessionRepository.getById:query", |
| 96 | detail: "sqlite is unavailable", |
| 97 | }); |
| 98 | |
| 99 | const 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 | |
| 111 | const 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 | |
| 122 | it.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 | }); |