1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {
3 authScopeResponse,
4 AuthAdministrativeScopes,
5 AuthStandardClientScopes,
6} from "@t3tools/contracts";
7import { expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Redacted from "effect/Redacted";
11import * as Schema from "effect/Schema";
12import * as SqlClient from "effect/sql/SqlClient";
13
14import * as ServerConfig from "../config.ts";
15import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
16import * as PersistenceErrors from "../persistence/Errors.ts";
17import * as SqlitePersistence from "../persistence/Sqlite.ts";
18import * as PairingGrantStore from "./PairingGrantStore.ts";
19import * as EnvironmentAuth from "./EnvironmentAuth.ts";
20
21import * as ServerSecretStore from "./ServerSecretStore.ts";
22import * as SessionStore from "./SessionStore.ts";
23
24/** Pinned so dev-mode cookie tests can assert the port-scoped name. */
25const TEST_SERVER_PORT = 13_773;
26const isPairingCredentialIssueError = Schema.is(PairingGrantStore.PairingCredentialIssueError);
27const isPersistenceSqlError = Schema.is(PersistenceErrors.PersistenceSqlError);
28
29const layerServerConfig = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
30 Layer.effect(
31 ServerConfig.ServerConfig,
32 Effect.gen(function* () {
33 const config = yield* ServerConfig.ServerConfig;
34 return {
35 ...config,
36 ...overrides,
37 // Keep the test server deterministic even when the default test layer
38 // changes its development port.
39 port: TEST_SERVER_PORT,
40 } satisfies ServerConfig.ServerConfig["Service"];
41 }),
42 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));
43
44const layerEnvironmentAuth = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
45 EnvironmentAuth.layer.pipe(
46 Layer.provideMerge(SqlitePersistence.layerMemory),
47 Layer.provide(ServerSecretStore.layer),
48 Layer.provide(ServerEnvironment.layerIdentity),
49 Layer.provide(layerServerConfig(overrides)),
50 );
51
52const makeCookieRequest = (
53 cookieName: string,
54 sessionToken: string,
55): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] =>
56 ({
57 cookies: {
58 [cookieName]: sessionToken,
59 },
60 headers: {},
61 }) as unknown as Parameters<
62 EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
63 >[0];
64
65const makeBearerRequest = (
66 token: string,
67): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] =>
68 ({
69 cookies: {},
70 headers: {
71 authorization: `Bearer ${token}`,
72 },
73 }) as unknown as Parameters<
74 EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
75 >[0];
76
77const requestMetadata = {
78 deviceType: "desktop" as const,
79 os: "macOS",
80 browser: "Chrome",
81 ipAddress: "192.168.1.23",
82};
83
84it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
85 it.effect("uses the reusable dev cookie without overriding a normal scoped cookie", () =>
86 Effect.gen(function* () {
87 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
88 const sessions = yield* SessionStore.SessionStore;
89 const token = "reusable-dev-auth-token-that-is-long-enough";
90 const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
91 const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] });
92 const scopedExchange = yield* serverAuth.createBrowserSession(
93 pairing.credential,
94 requestMetadata,
95 );
96 const request = {
97 cookies: {
98 [sessions.cookieName]: scopedExchange.sessionToken,
99 [devExchange.cookieName ?? "missing"]: devExchange.sessionToken,
100 },
101 headers: {},
102 } as unknown as Parameters<
103 EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
104 >[0];
105
106 const authenticated = yield* serverAuth.authenticateHttpRequest(request);
107 expect(devExchange.cookieName).toMatch(/^t3_dev_session_/);
108 expect(devExchange.expireNormalCookie).toBe(true);
109 expect(devExchange.response).toMatchObject(authScopeResponse(AuthAdministrativeScopes));
110 expect(authenticated.scopes).toEqual(["orchestration:read"]);
111 }).pipe(
112 Effect.provide(
113 layerEnvironmentAuth({
114 mode: "web",
115 devUrl: new URL("http://127.0.0.1:5173"),
116 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
117 }),
118 ),
119 ),
120 );
121
122 it.effect("does not fall back to the dev cookie after a normal cookie is rejected", () =>
123 Effect.gen(function* () {
124 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
125 const sessions = yield* SessionStore.SessionStore;
126 const token = "reusable-dev-auth-token-that-is-long-enough";
127 const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
128 const pairing = yield* serverAuth.issuePairingCredential({ scopes: ["orchestration:read"] });
129 const scopedExchange = yield* serverAuth.createBrowserSession(
130 pairing.credential,
131 requestMetadata,
132 );
133 const scoped = yield* sessions.verify(scopedExchange.sessionToken);
134 yield* sessions.revoke(scoped.sessionId);
135 const request = {
136 cookies: {
137 [sessions.cookieName]: scopedExchange.sessionToken,
138 [devExchange.cookieName ?? "missing"]: devExchange.sessionToken,
139 },
140 headers: {},
141 } as unknown as Parameters<
142 EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
143 >[0];
144
145 const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request));
146 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
147 }).pipe(
148 Effect.provide(
149 layerEnvironmentAuth({
150 mode: "web",
151 devUrl: new URL("http://127.0.0.1:5173"),
152 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
153 }),
154 ),
155 ),
156 );
157
158 it.effect("does not use the dev cookie when Authorization is invalid or empty", () =>
159 Effect.gen(function* () {
160 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
161 const token = "reusable-dev-auth-token-that-is-long-enough";
162 const devExchange = yield* serverAuth.createBrowserSession(token, requestMetadata);
163 for (const authorization of ["Bearer invalid", ""] as const) {
164 const request = {
165 cookies: { [devExchange.cookieName ?? "missing"]: devExchange.sessionToken },
166 headers: { authorization },
167 } as unknown as Parameters<
168 EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]
169 >[0];
170 const error = yield* Effect.flip(serverAuth.authenticateHttpRequest(request));
171 expect(EnvironmentAuth.isServerAuthCredentialError(error)).toBe(true);
172 }
173 }).pipe(
174 Effect.provide(
175 layerEnvironmentAuth({
176 mode: "web",
177 devUrl: new URL("http://127.0.0.1:5173"),
178 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
179 }),
180 ),
181 ),
182 );
183
184 it.effect("exchanges the reusable dev token for a local scoped OAuth session", () =>
185 Effect.gen(function* () {
186 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
187 const sessions = yield* SessionStore.SessionStore;
188 const token = "reusable-dev-auth-token-that-is-long-enough";
189 const exchanged = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
190 token,
191 ["orchestration:read"],
192 requestMetadata,
193 );
194
195 expect(exchanged.access_token).not.toBe(token);
196 expect(exchanged.scope).toBe("orchestration:read");
197 const dpop = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
198 token,
199 ["orchestration:read"],
200 requestMetadata,
201 { proofKeyThumbprint: "test-proof-key" },
202 );
203 expect(dpop.access_token).not.toBe(token);
204 expect(dpop.access_token).not.toBe(exchanged.access_token);
205 expect(dpop.token_type).toBe("DPoP");
206 expect(dpop.scope).toBe("orchestration:read");
207
208 const secondBearer = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
209 token,
210 ["orchestration:read"],
211 requestMetadata,
212 );
213 const firstSession = yield* serverAuth.authenticateHttpRequest(
214 makeBearerRequest(exchanged.access_token),
215 );
216 const secondSession = yield* serverAuth.authenticateHttpRequest(
217 makeBearerRequest(secondBearer.access_token),
218 );
219 expect(firstSession.subject).toBe("reusable-dev-token-child");
220 expect(secondSession.subject).toBe("reusable-dev-token-child");
221 expect((yield* sessions.verify(token)).subject).toBe("reusable-dev-token");
222 const before = yield* serverAuth.listClientSessions(firstSession.sessionId);
223 const denied = yield* serverAuth
224 .exchangeBootstrapCredentialForAccessToken(token, ["review:write"], requestMetadata)
225 .pipe(Effect.flip);
226 expect(denied._tag).toBe("ServerAuthScopeNotGrantedError");
227 const empty = yield* serverAuth
228 .exchangeBootstrapCredentialForAccessToken(token, [], requestMetadata)
229 .pipe(Effect.flip);
230 expect(empty._tag).toBe("ServerAuthScopeNotGrantedError");
231 expect(yield* serverAuth.listClientSessions(firstSession.sessionId)).toEqual(before);
232 }).pipe(
233 Effect.provide(
234 layerEnvironmentAuth({
235 mode: "web",
236 devUrl: new URL("http://127.0.0.1:5173"),
237 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
238 }),
239 ),
240 ),
241 );
242
243 it.effect("uses a one-time startup credential after local dev token revocation", () =>
244 Effect.gen(function* () {
245 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
246 const sessions = yield* SessionStore.SessionStore;
247 const token = "reusable-dev-auth-token-that-is-long-enough";
248 const initial = yield* serverAuth.issueStartupPairingCredential();
249 const seeded = yield* sessions.verify(token);
250
251 yield* sessions.revoke(seeded.sessionId);
252 const recovery = yield* serverAuth.issueStartupPairingCredential();
253
254 expect(initial.credential).toBe(token);
255 expect(recovery.credential).not.toBe(token);
256 expect((yield* Effect.flip(sessions.verify(token)))._tag).toBe("SessionTokenRevokedError");
257 expect(
258 (yield* serverAuth.createBrowserSession(recovery.credential, requestMetadata)).response,
259 ).toMatchObject({ authenticated: true, ...authScopeResponse(AuthAdministrativeScopes) });
260 }).pipe(
261 Effect.provide(
262 layerEnvironmentAuth({
263 mode: "web",
264 devUrl: new URL("http://127.0.0.1:5173"),
265 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
266 }),
267 ),
268 ),
269 );
270
271 it.effect("keeps the pairing issue error as the immediate recovery failure", () =>
272 Effect.gen(function* () {
273 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
274 const sessions = yield* SessionStore.SessionStore;
275 const sql = yield* SqlClient.SqlClient;
276 const token = "reusable-dev-auth-token-that-is-long-enough";
277 const seeded = yield* sessions.verify(token);
278
279 yield* sessions.revoke(seeded.sessionId);
280 yield* sql`
281 CREATE TRIGGER reject_startup_pairing_link
282 BEFORE INSERT ON auth_pairing_links
283 BEGIN
284 SELECT RAISE(ABORT, 'startup pairing insert rejected');
285 END
286 `;
287
288 const error = yield* Effect.flip(serverAuth.issueStartupPairingCredential());
289
290 expect(error._tag).toBe("ServerAuthPairingLinkCreationError");
291 expect(isPairingCredentialIssueError(error.cause)).toBe(true);
292 if (isPairingCredentialIssueError(error.cause)) {
293 expect(isPersistenceSqlError(error.cause.cause)).toBe(true);
294 }
295 }).pipe(
296 Effect.provide(
297 layerEnvironmentAuth({
298 mode: "web",
299 devUrl: new URL("http://127.0.0.1:5173"),
300 devAuthToken: Redacted.make("reusable-dev-auth-token-that-is-long-enough"),
301 }),
302 ),
303 ),
304 );
305
306 it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () =>
307 Effect.sync(() => {
308 const error = EnvironmentAuth.toBootstrapExchangeError(
309 new PairingGrantStore.UnknownBootstrapCredentialError({}),
310 );
311
312 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
313 }),
314 );
315
316 it.effect("maps unexpected bootstrap failures to 500", () =>
317 Effect.sync(() => {
318 const cause = new PairingGrantStore.BootstrapCredentialConsumeError({
319 cause: new Error("sqlite is unavailable"),
320 });
321 const error = EnvironmentAuth.toBootstrapExchangeError(cause);
322
323 expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError");
324 expect(error.message).toBe("Failed to validate bootstrap credential.");
325 if (error._tag === "ServerAuthBootstrapCredentialValidationError") {
326 expect(error.cause).toBe(cause);
327 }
328 }),
329 );
330
331 it.effect("issues standard pairing credentials by default", () =>
332 Effect.gen(function* () {
333 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
334 const sessions = yield* SessionStore.SessionStore;
335
336 const pairingCredential = yield* serverAuth.issuePairingCredential();
337 const exchanged = yield* serverAuth.createBrowserSession(
338 pairingCredential.credential,
339 requestMetadata,
340 );
341 const verified = yield* serverAuth.authenticateHttpRequest(
342 makeCookieRequest(sessions.cookieName, exchanged.sessionToken),
343 );
344
345 expect(verified.sessionId.length).toBeGreaterThan(0);
346 expect(verified.scopes).toEqual(AuthStandardClientScopes);
347 expect(verified.subject).toBe("one-time-token");
348 }).pipe(Effect.provide(layerEnvironmentAuth())),
349 );
350
351 it.effect("prefers a bearer token over a stale legacy cookie", () =>
352 Effect.gen(function* () {
353 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
354 const sessions = yield* SessionStore.SessionStore;
355 const bearer = yield* serverAuth.issueSession();
356 const verified = yield* serverAuth.authenticateHttpRequest({
357 cookies: { [sessions.legacyCookieName ?? "t3_session"]: "stale" },
358 headers: { authorization: `Bearer ${bearer.token}` },
359 } as never);
360
361 expect(verified.sessionId).toBe(bearer.sessionId);
362 }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))),
363 );
364
365 it.effect("preserves pairing grants after rejecting scopes they do not grant", () =>
366 Effect.gen(function* () {
367 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
368 const pairingCredential = yield* serverAuth.issuePairingCredential();
369
370 const error = yield* serverAuth
371 .exchangeBootstrapCredentialForAccessToken(
372 pairingCredential.credential,
373 ["access:write"],
374 requestMetadata,
375 )
376 .pipe(Effect.flip);
377
378 expect(error._tag).toBe("ServerAuthScopeNotGrantedError");
379 expect((yield* serverAuth.listPairingLinks()).map((link) => link.id)).toContain(
380 pairingCredential.id,
381 );
382 expect(yield* serverAuth.listSessions()).toEqual([]);
383
384 const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
385 pairingCredential.credential,
386 ["orchestration:read"],
387 requestMetadata,
388 );
389 const session = yield* serverAuth.authenticateHttpRequest(
390 makeBearerRequest(token.access_token),
391 );
392
393 expect(token.scope).toBe("orchestration:read");
394 expect(session.scopes).toEqual(["orchestration:read"]);
395 expect((yield* serverAuth.listPairingLinks()).map((link) => link.id)).not.toContain(
396 pairingCredential.id,
397 );
398 const reused = yield* serverAuth
399 .exchangeBootstrapCredentialForAccessToken(
400 pairingCredential.credential,
401 ["orchestration:read"],
402 requestMetadata,
403 )
404 .pipe(Effect.flip);
405 expect(reused._tag).toBe("ServerAuthInvalidCredentialError");
406 }).pipe(Effect.provide(layerEnvironmentAuth())),
407 );
408
409 it.effect.each([
410 { label: "omits scope", requestedScopes: undefined },
411 {
412 label: "requests unsupported permissions alongside a granted one",
413 requestedScopes: ["orchestration:read", "access:write"] as const,
414 },
415 ])("inherits a constrained pairing grant when token exchange $label", ({ requestedScopes }) =>
416 Effect.gen(function* () {
417 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
418 const pairingCredential = yield* serverAuth.issuePairingCredential({
419 scopes: ["orchestration:read"],
420 });
421
422 const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
423 pairingCredential.credential,
424 requestedScopes,
425 requestMetadata,
426 );
427
428 expect(token.scope).toBe("orchestration:read");
429 }).pipe(Effect.provide(layerEnvironmentAuth())),
430 );
431
432 it.effect("narrows seeded desktop grants to the requested scopes", () =>
433 Effect.gen(function* () {
434 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
435 const token = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
436 "desktop-bootstrap-token",
437 ["orchestration:read"],
438 requestMetadata,
439 );
440 const session = yield* serverAuth.authenticateHttpRequest(
441 makeBearerRequest(token.access_token),
442 );
443
444 expect(token.scope).toBe("orchestration:read");
445 expect(session.scopes).toEqual(["orchestration:read"]);
446 }).pipe(
447 Effect.provide(layerEnvironmentAuth({ desktopBootstrapToken: "desktop-bootstrap-token" })),
448 ),
449 );
450
451 it.effect("rotates desktop bearer sessions without accumulating authorized clients", () =>
452 Effect.gen(function* () {
453 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
454 const sessions = yield* SessionStore.SessionStore;
455 const browser = yield* serverAuth.createBrowserSession(
456 "desktop-bootstrap-token",
457 requestMetadata,
458 );
459 const browserSession = yield* serverAuth.authenticateHttpRequest(
460 makeCookieRequest(sessions.cookieName, browser.sessionToken),
461 );
462 const staleSessions = yield* Effect.forEach([1, 2, 3], () =>
463 sessions.issue({ subject: "desktop-bootstrap", method: "bearer-access-token" }),
464 );
465 const pairing = yield* serverAuth.issuePairingCredential();
466 const paired = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
467 pairing.credential,
468 undefined,
469 { ...requestMetadata, label: "T3 Code Desktop" },
470 );
471 const first = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
472 "desktop-bootstrap-token",
473 undefined,
474 requestMetadata,
475 );
476 const firstSession = yield* serverAuth.authenticateHttpRequest(
477 makeBearerRequest(first.access_token),
478 );
479 const second = yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
480 "desktop-bootstrap-token",
481 undefined,
482 requestMetadata,
483 );
484
485 const active = yield* serverAuth.listSessions();
486 const firstError = yield* serverAuth
487 .authenticateHttpRequest(makeBearerRequest(first.access_token))
488 .pipe(Effect.flip);
489 const secondSession = yield* serverAuth.authenticateHttpRequest(
490 makeBearerRequest(second.access_token),
491 );
492
493 expect(active).toHaveLength(3);
494 expect(active.map((entry) => entry.sessionId)).toContain(browserSession.sessionId);
495 expect(active.map((entry) => entry.sessionId)).toContain(secondSession.sessionId);
496 expect(active.map((entry) => entry.sessionId)).not.toContain(firstSession.sessionId);
497 expect(firstError._tag).toBe("ServerAuthInvalidCredentialError");
498 for (const stale of staleSessions) {
499 const error = yield* sessions.verify(stale.token).pipe(Effect.flip);
500 expect(error._tag).toBe("SessionTokenRevokedError");
501 }
502 const pairedSession = yield* serverAuth.authenticateHttpRequest(
503 makeBearerRequest(paired.access_token),
504 );
505 expect(pairedSession.subject).toBe("one-time-token");
506 expect(active.map((entry) => entry.sessionId)).toContain(pairedSession.sessionId);
507 }).pipe(
508 Effect.provide(
509 layerEnvironmentAuth({
510 desktopBootstrapToken: "desktop-bootstrap-token",
511 }),
512 ),
513 ),
514 );
515
516 it.effect("keeps user-issued administrative pairing links manageable", () =>
517 Effect.gen(function* () {
518 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
519 const pairingCredential = yield* serverAuth.issuePairingCredential({
520 scopes: AuthAdministrativeScopes,
521 });
522 const listedPairingLinks = yield* serverAuth.listPairingLinks();
523
524 expect(
525 listedPairingLinks.find((pairingLink) => pairingLink.id === pairingCredential.id)?.subject,
526 ).toBe("one-time-token");
527 }).pipe(Effect.provide(layerEnvironmentAuth())),
528 );
529
530 it.effect("issues startup pairing URLs that bootstrap administrative sessions", () =>
531 Effect.gen(function* () {
532 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
533 const sessions = yield* SessionStore.SessionStore;
534
535 const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773");
536 const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token");
537 const listedPairingLinks = yield* serverAuth.listPairingLinks();
538 expect(token).toBeTruthy();
539 expect(
540 listedPairingLinks.some(
541 (pairingLink) => pairingLink.subject === "administrative-bootstrap",
542 ),
543 ).toBe(false);
544
545 const exchanged = yield* serverAuth.createBrowserSession(token ?? "", requestMetadata);
546 const verified = yield* serverAuth.authenticateHttpRequest(
547 makeCookieRequest(sessions.cookieName, exchanged.sessionToken),
548 );
549
550 expect(verified.scopes).toEqual(AuthAdministrativeScopes);
551 expect(verified.subject).toBe("administrative-bootstrap");
552 }).pipe(Effect.provide(layerEnvironmentAuth())),
553 );
554
555 it.effect(
556 "lists pairing links and revokes other sessions while keeping the administrative session",
557 () =>
558 Effect.gen(function* () {
559 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
560 const sessions = yield* SessionStore.SessionStore;
561
562 const administrativeExchange = yield* serverAuth.createBrowserSession(
563 "desktop-bootstrap-token",
564 requestMetadata,
565 );
566 const administrativeSession = yield* serverAuth.authenticateHttpRequest(
567 makeCookieRequest(sessions.cookieName, administrativeExchange.sessionToken),
568 );
569 const pairingCredential = yield* serverAuth.issuePairingCredential({
570 label: "Julius iPhone",
571 });
572 const listedPairingLinks = yield* serverAuth.listPairingLinks();
573 const clientExchange = yield* serverAuth.createBrowserSession(
574 pairingCredential.credential,
575 {
576 ...requestMetadata,
577 deviceType: "mobile",
578 os: "iOS",
579 browser: "Safari",
580 ipAddress: "192.168.1.88",
581 },
582 );
583 const clientSession = yield* serverAuth.authenticateHttpRequest(
584 makeCookieRequest(sessions.cookieName, clientExchange.sessionToken),
585 );
586 const clientsBeforeRevoke = yield* serverAuth.listClientSessions(
587 administrativeSession.sessionId,
588 );
589 const revokedCount = yield* serverAuth.revokeOtherClientSessions(
590 administrativeSession.sessionId,
591 );
592 const clientsAfterRevoke = yield* serverAuth.listClientSessions(
593 administrativeSession.sessionId,
594 );
595
596 expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id);
597 expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe(
598 "Julius iPhone",
599 );
600 expect(clientsBeforeRevoke).toHaveLength(2);
601 expect(
602 clientsBeforeRevoke.find((entry) => entry.sessionId === administrativeSession.sessionId)
603 ?.current,
604 ).toBe(true);
605 expect(
606 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current,
607 ).toBe(false);
608 expect(
609 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
610 .label,
611 ).toBe("Julius iPhone");
612 expect(
613 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
614 .deviceType,
615 ).toBe("mobile");
616 expect(revokedCount).toBe(1);
617 expect(clientsAfterRevoke).toHaveLength(1);
618 expect(clientsAfterRevoke[0]?.sessionId).toBe(administrativeSession.sessionId);
619 }).pipe(
620 Effect.provide(
621 layerEnvironmentAuth({
622 desktopBootstrapToken: "desktop-bootstrap-token",
623 }),
624 ),
625 ),
626 );
627
628 it.effect("accepts cookie sessions on socket upgrades only from the app's own origins", () =>
629 Effect.gen(function* () {
630 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
631 const sessions = yield* SessionStore.SessionStore;
632 const pairing = yield* serverAuth.issuePairingCredential();
633 const exchanged = yield* serverAuth.createBrowserSession(pairing.credential, requestMetadata);
634 const upgrade = (headers: Record<string, string>) =>
635 serverAuth.authenticateWebSocketUpgrade({
636 url: "/ws",
637 cookies: { [sessions.cookieName]: exchanged.sessionToken },
638 headers: { host: "192.168.1.50:3773", ...headers },
639 } as never);
640
641 // Same host, a proxy-reported host, a trusted app origin, or no browser at all.
642 yield* upgrade({ origin: "http://192.168.1.50:3773" });
643 yield* upgrade({
644 origin: "https://box.tail1234.ts.net",
645 "x-forwarded-host": "box.tail1234.ts.net",
646 });
647 yield* upgrade({ origin: "t3code://app" });
648 yield* upgrade({ origin: "http://localhost:5733" });
649 yield* upgrade({});
650
651 // Another port on the same machine is the same site but not the app.
652 const otherPort = yield* Effect.flip(upgrade({ origin: "http://192.168.1.50:8080" }));
653 expect(otherPort._tag).toBe("ServerAuthInvalidCredentialError");
654 const opaque = yield* Effect.flip(upgrade({ origin: "null" }));
655 expect(opaque._tag).toBe("ServerAuthInvalidCredentialError");
656 }).pipe(
657 Effect.provide(
658 layerEnvironmentAuth({
659 mode: "web",
660 host: "192.168.1.50",
661 devUrl: new URL("http://localhost:5733"),
662 }),
663 ),
664 ),
665 );
666
667 it.effect("accepts cookie sessions on HTTP requests only from the app's own origins", () =>
668 Effect.gen(function* () {
669 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
670 const sessions = yield* SessionStore.SessionStore;
671 const pairing = yield* serverAuth.issuePairingCredential();
672 const exchanged = yield* serverAuth.createBrowserSession(pairing.credential, requestMetadata);
673 const post = (headers: Record<string, string>) =>
674 serverAuth.authenticateHttpRequest({
675 cookies: { [sessions.cookieName]: exchanged.sessionToken },
676 headers: { host: "localhost:13773", ...headers },
677 } as never);
678
679 // The dev proxy rewrites Host, so a LAN page relies on the browser's own verdict.
680 yield* post({ origin: "http://192.168.1.50:5733", "sec-fetch-site": "same-origin" });
681 yield* post({ origin: "http://localhost:13773" });
682
683 const otherPort = yield* Effect.flip(
684 post({ origin: "http://localhost:3000", "sec-fetch-site": "same-site" }),
685 );
686 expect(otherPort._tag).toBe("ServerAuthInvalidCredentialError");
687 const state = yield* serverAuth.getSessionState({
688 cookies: { [sessions.cookieName]: exchanged.sessionToken },
689 headers: { host: "localhost:13773", origin: "http://localhost:3000" },
690 } as never);
691 expect(state.authenticated).toBe(false);
692 }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web" }))),
693 );
694
695 it.effect("keeps bearer socket upgrades independent of the page origin", () =>
696 Effect.gen(function* () {
697 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
698 const bearer = yield* serverAuth.issueSession();
699 const verified = yield* serverAuth.authenticateWebSocketUpgrade({
700 url: "/ws",
701 cookies: {},
702 headers: {
703 host: "192.168.1.50:3773",
704 origin: "http://192.168.1.50:8080",
705 authorization: `Bearer ${bearer.token}`,
706 },
707 } as never);
708 expect(verified.sessionId).toBe(bearer.sessionId);
709 }).pipe(Effect.provide(layerEnvironmentAuth({ mode: "web", host: "192.168.1.50" }))),
710 );
711 it.effect("delegates only the parent grant and rejects access after its revocation", () =>
712 Effect.gen(function* () {
713 const auth = yield* EnvironmentAuth.EnvironmentAuth;
714 const parent = yield* auth.issueSession();
715 const issued = yield* auth.issueDelegatedSession(parent, {
716 scopes: ["orchestration:read", "access:write"],
717 });
718 const child = yield* auth.authenticateHttpRequest(makeBearerRequest(issued.access_token));
719 expect(child.parentSessionId).toBe(parent.sessionId);
720 expect(issued.token_type).toBe("Bearer");
721 expect(issued.expires_in).toBe(3600);
722 expect(
723 (yield* auth
724 .issueDelegatedSession(child, { scopes: ["orchestration:read"] })
725 .pipe(Effect.flip))._tag,
726 ).toBe("ServerAuthScopeNotGrantedError");
727 const limited = yield* auth.issueSession({ scopes: ["access:write", "orchestration:read"] });
728 expect(
729 (yield* auth
730 .issueDelegatedSession(limited, { scopes: ["orchestration:operate"] })
731 .pipe(Effect.flip))._tag,
732 ).toBe("ServerAuthScopeNotGrantedError");
733 expect(
734 (yield* auth
735 .issueDelegatedSession(parent, { scopes: ["orchestration:read", "orchestration:read"] })
736 .pipe(Effect.flip))._tag,
737 ).toBe("ServerAuthInvalidScopeError");
738 yield* auth.revokeSession(parent.sessionId);
739 expect(
740 (yield* auth
741 .authenticateHttpRequest(makeBearerRequest(issued.access_token))
742 .pipe(Effect.flip))._tag,
743 ).toBe("ServerAuthInvalidCredentialError");
744 }).pipe(Effect.provide(layerEnvironmentAuth())),
745 );
746});