Skip to content
Source
Sign inSign up
Open History to see the last commit

EnvironmentAuth.test.ts

828 lines33.8 KB

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