1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { EnvironmentId, HostedAuthAccount } from "@t3tools/contracts";
4import { exportJWK, generateKeyPair, SignJWT, type JWTPayload } from "jose";
5import * as Crypto from "effect/Crypto";
6import * as Base64Url from "effect/encoding/Base64Url";
7import * as DateTime from "effect/DateTime";
8import * as FileSystem from "effect/FileSystem";
9import * as ConfigProvider from "effect/ConfigProvider";
10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Redacted from "effect/Redacted";
17import * as Schema from "effect/Schema";
18import * as TestClock from "effect/testing/TestClock";
19import { HttpClient, HttpClientResponse, HttpRouter, HttpServerRequest } from "effect/http";
20import * as ServerConfig from "../config.ts";
21import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
22import * as SqlitePersistence from "../persistence/Sqlite.ts";
23import * as EnvironmentAuth from "./EnvironmentAuth.ts";
24import * as HostedAuth from "./HostedAuth.ts";
25import * as HostedAuthConfig from "./HostedAuthConfig.ts";
26import * as HostedAuthHttp from "./HostedAuthHttp.ts";
27import * as ServerSecretStore from "./ServerSecretStore.ts";
28import * as SessionStore from "./SessionStore.ts";
29
30const settings = {
31 publicUrl: "https://knife.example",
32 issuer: "https://issuer.example",
33 clientId: "knife-test",
34 clientSecret: Redacted.make("test-client-secret"),
35 ownerSub: "configured-owner",
36 maxInteractiveAgeSeconds: 86_400,
37 issuerRevalidationSeconds: 60,
38};
39const client = { label: "Test computer", deviceType: "desktop" } as const;
40const verifier = "v".repeat(43);
41const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
42
43const fixture = Effect.gen(function* () {
44 const key = yield* Effect.promise(() => generateKeyPair("RS256"));
45 const jwk = yield* Effect.promise(() => exportJWK(key.publicKey));
46 let nonce = "";
47 let claims: JWTPayload = {};
48 let refreshDenied = false;
49 let refreshCalls = 0;
50 let refreshGate:
51 | { entered: Deferred.Deferred<void>; release: Deferred.Deferred<void> }
52 | undefined;
53 let lastTokenBody: URLSearchParams | undefined;
54 const http = HttpClient.make((request) =>
55 Effect.gen(function* () {
56 const url = new URL(request.url);
57 if (url.pathname.endsWith("openid-configuration"))
58 return HttpClientResponse.fromWeb(
59 request,
60 Response.json({
61 issuer: settings.issuer,
62 authorization_endpoint: `${settings.issuer}/authorize`,
63 token_endpoint: `${settings.issuer}/token`,
64 jwks_uri: `${settings.issuer}/jwks`,
65 code_challenge_methods_supported: ["S256"],
66 response_types_supported: ["code"],
67 token_endpoint_auth_methods_supported: ["client_secret_basic"],
68 id_token_signing_alg_values_supported: ["RS256"],
69 }),
70 );
71 if (url.pathname === "/jwks")
72 return HttpClientResponse.fromWeb(
73 request,
74 Response.json({ keys: [{ ...jwk, kid: "test" }] }),
75 );
76 if (request.body._tag !== "Uint8Array") return yield* Effect.die("Expected token form");
77 lastTokenBody = new URLSearchParams(new TextDecoder().decode(request.body.body));
78 if (lastTokenBody.get("grant_type") === "refresh_token") {
79 refreshCalls++;
80 if (refreshGate) {
81 yield* Deferred.succeed(refreshGate.entered, undefined);
82 yield* Deferred.await(refreshGate.release);
83 }
84 if (refreshDenied)
85 return HttpClientResponse.fromWeb(
86 request,
87 Response.json({ error: "invalid_grant" }, { status: 400 }),
88 );
89 }
90 const now = Math.floor((yield* DateTime.now).epochMilliseconds / 1_000);
91 const token = yield* Effect.promise(() =>
92 new SignJWT({
93 nonce,
94 sub: settings.ownerSub,
95 iss: settings.issuer,
96 aud: settings.clientId,
97 iat: now,
98 exp: now + 3_600,
99 ...claims,
100 })
101 .setProtectedHeader({ alg: "RS256", kid: "test" })
102 .sign(key.privateKey),
103 );
104 return HttpClientResponse.fromWeb(
105 request,
106 Response.json({
107 access_token: "issuer-access-never-delivered",
108 refresh_token: "issuer-refresh-never-delivered",
109 id_token: token,
110 token_type: "Bearer",
111 }),
112 );
113 }),
114 );
115 const layer = HostedAuth.layer.pipe(
116 Layer.provideMerge(EnvironmentAuth.layer),
117 Layer.provideMerge(SqlitePersistence.layerMemory),
118 Layer.provideMerge(ServerSecretStore.layer),
119 Layer.provide(
120 Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, {
121 getEnvironmentId: Effect.succeed(EnvironmentId.make("test-hub")),
122 }),
123 ),
124 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "knife-hosted-auth-" })),
125 Layer.provideMerge(Layer.succeed(HostedAuthConfig.HostedAuthConfig, Option.some(settings))),
126 Layer.provide(Layer.succeed(HttpClient.HttpClient, http)),
127 );
128 return {
129 layer,
130 setNonce: (value: string) => {
131 nonce = value;
132 },
133 setClaims: (value: JWTPayload) => {
134 claims = value;
135 },
136 denyRefresh: () => {
137 refreshDenied = true;
138 },
139 pauseRefresh: (entered: Deferred.Deferred<void>, release: Deferred.Deferred<void>) => {
140 refreshGate = { entered, release };
141 },
142 refreshCalls: () => refreshCalls,
143 lastTokenBody: () => lastTokenBody,
144 };
145});
146
147const beginLogin = (f: Effect.Success<typeof fixture>, native = true) =>
148 Effect.gen(function* () {
149 const auth = yield* HostedAuth.HostedAuth;
150 let request: string | undefined;
151 if (native) {
152 const challenge = Base64Url.encode(
153 yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(verifier)),
154 );
155 const start = yield* auth.startDevice({
156 codeChallenge: challenge,
157 state: "s".repeat(43),
158 client,
159 });
160 request = new URL(start.authorizationUrl).searchParams.get("request") ?? undefined;
161 }
162 const begun = yield* auth.begin(request ? { request } : { returnTo: "/projects?selected=one" });
163 const url = new URL(begun.url);
164 f.setNonce(url.searchParams.get("nonce") ?? "");
165 return {
166 ...begun,
167 state: url.searchParams.get("state") ?? "",
168 challenge: url.searchParams.get("code_challenge") ?? "",
169 };
170 });
171const nativeLogin = (f: Effect.Success<typeof fixture>) =>
172 Effect.gen(function* () {
173 const auth = yield* HostedAuth.HostedAuth;
174 const begun = yield* beginLogin(f);
175 const callback = yield* auth.callback({ ...begun, code: "issuer-code", client });
176 const url = new URL(callback.url);
177 expect(url.protocol).toBe("t3code:");
178 expect(url.host + url.pathname).toBe("app/self-hosted/callback");
179 expect(url.searchParams.get("state")).toBe("s".repeat(43));
180 expect(url.searchParams.get("server")).toBe(settings.publicUrl);
181 const code = url.searchParams.get("code") ?? "";
182 return { grant: yield* auth.exchange({ code, codeVerifier: verifier }), code };
183 });
184
185it.layer(NodeServices.layer)("HostedAuth", (it) => {
186 it.effect(
187 "keeps issuer credentials server-side and binds a single-use handoff to the native proof",
188 () =>
189 Effect.gen(function* () {
190 const f = yield* fixture;
191 yield* Effect.gen(function* () {
192 const auth = yield* HostedAuth.HostedAuth;
193 const begun = yield* beginLogin(f);
194 const error = yield* auth
195 .callback({ ...begun, browserCookie: "another-browser", code: "code", client })
196 .pipe(Effect.flip);
197 expect(error.reason).toBe("transaction");
198 const second = yield* beginLogin(f);
199 const callback = yield* auth.callback({ ...second, code: "code", client });
200 const code = new URL(callback.url).searchParams.get("code") ?? "";
201 expect(
202 (yield* auth.exchange({ code, codeVerifier: "x".repeat(43) }).pipe(Effect.flip)).reason,
203 ).toBe("credential");
204 const grant = yield* auth.exchange({ code, codeVerifier: verifier });
205 expect(grant.refreshToken).not.toContain("issuer-");
206 expect(grant.accessToken).not.toContain("issuer-");
207 const submitted = f.lastTokenBody()?.get("code_verifier") ?? "";
208 expect(
209 Base64Url.encode(
210 yield* (yield* Crypto.Crypto).digest("SHA-256", new TextEncoder().encode(submitted)),
211 ),
212 ).toBe(second.challenge);
213 expect(f.lastTokenBody()?.get("redirect_uri")).toBe(
214 `${settings.publicUrl}/api/auth/oidc/callback`,
215 );
216 expect(
217 (yield* auth.exchange({ code, codeVerifier: verifier }).pipe(Effect.flip)).reason,
218 ).toBe("credential");
219 const session = yield* (yield* SessionStore.SessionStore).verify(grant.accessToken);
220 expect(session.deviceId).toBe(grant.deviceId);
221 }).pipe(Effect.provide(f.layer));
222 }),
223 );
224
225 it.effect.each([
226 { sub: "another-user" },
227 { iss: "https://another-issuer.example" },
228 { aud: "another-client" },
229 { azp: "another-client" },
230 { nonce: "another-transaction" },
231 { aud: [settings.clientId, "another-client"], azp: undefined },
232 ])("rejects an untrusted ID token %#", (claims) =>
233 Effect.gen(function* () {
234 const f = yield* fixture;
235 f.setClaims(claims);
236 yield* Effect.gen(function* () {
237 const auth = yield* HostedAuth.HostedAuth;
238 const begun = yield* beginLogin(f);
239 expect(
240 (yield* auth.callback({ ...begun, code: "code", client }).pipe(Effect.flip)).reason,
241 ).toBe("identity");
242 }).pipe(Effect.provide(f.layer));
243 }),
244 );
245
246 it.effect("rotates refresh tokens and revokes the device family on authentic reuse", () =>
247 Effect.gen(function* () {
248 const f = yield* fixture;
249 yield* Effect.gen(function* () {
250 const auth = yield* HostedAuth.HostedAuth;
251 const sessions = yield* SessionStore.SessionStore;
252 const { grant } = yield* nativeLogin(f);
253 const renewed = yield* auth.refresh(grant.refreshToken);
254 expect(renewed.deviceId).toBe(grant.deviceId);
255 expect(renewed.refreshToken).not.toBe(grant.refreshToken);
256 expect((yield* auth.refresh(grant.refreshToken).pipe(Effect.flip)).reason).toBe(
257 "credential",
258 );
259 expect((yield* sessions.verify(renewed.accessToken).pipe(Effect.result))._tag).toBe(
260 "Failure",
261 );
262 expect((yield* auth.refresh(renewed.refreshToken).pipe(Effect.result))._tag).toBe(
263 "Failure",
264 );
265 }).pipe(Effect.provide(f.layer));
266 }),
267 );
268
269 it.effect(
270 "device revocation invalidates HTTP, websocket tickets and live streams while retaining manual sessions",
271 () =>
272 Effect.gen(function* () {
273 const f = yield* fixture;
274 yield* Effect.gen(function* () {
275 const auth = yield* HostedAuth.HostedAuth;
276 const sessions = yield* SessionStore.SessionStore;
277 const { grant } = yield* nativeLogin(f);
278 const session = yield* sessions.verify(grant.accessToken);
279 const manual = yield* sessions.issue({ subject: "existing-paired-client" });
280 const child = yield* sessions.issue({
281 parentSessionId: session.sessionId,
282 scopes: ["orchestration:read"],
283 });
284 expect((yield* sessions.verify(child.token)).deviceId).toBe(grant.deviceId);
285 const ticket = yield* sessions.issueWebSocketToken(session.sessionId);
286 const stopped = yield* sessions
287 .awaitInvalidation(session.sessionId)
288 .pipe(Effect.forkScoped({ startImmediately: true }));
289 yield* sessions.verifyActive(session.sessionId);
290 yield* sessions.verifyActive((yield* sessions.verify(child.token)).sessionId);
291 yield* auth.revoke(session, grant.deviceId);
292 expect((yield* sessions.verifyActive(session.sessionId).pipe(Effect.result))._tag).toBe(
293 "Failure",
294 );
295 yield* Fiber.join(stopped);
296 for (const token of [grant.accessToken, child.token])
297 expect((yield* sessions.verify(token).pipe(Effect.result))._tag).toBe("Failure");
298 expect(
299 (yield* sessions.verifyWebSocketToken(ticket.token).pipe(Effect.result))._tag,
300 ).toBe("Failure");
301 expect((yield* sessions.verify(manual.token)).subject).toBe("existing-paired-client");
302 }).pipe(Effect.provide(f.layer));
303 }),
304 );
305
306 it.effect("revalidates issuer renewal and stops a disabled issuer grant", () =>
307 Effect.gen(function* () {
308 const f = yield* fixture;
309 yield* Effect.gen(function* () {
310 const auth = yield* HostedAuth.HostedAuth;
311 const { grant } = yield* nativeLogin(f);
312 yield* TestClock.adjust(Duration.seconds(61));
313 f.denyRefresh();
314 expect((yield* auth.refresh(grant.refreshToken).pipe(Effect.flip)).reason).toBe(
315 "reauthentication",
316 );
317 expect(f.refreshCalls()).toBe(1);
318 expect(
319 (yield* (yield* SessionStore.SessionStore).verify(grant.accessToken).pipe(Effect.result))
320 ._tag,
321 ).toBe("Failure");
322 }).pipe(Effect.provide(f.layer));
323 }),
324 );
325
326 it.effect("attenuated tokens cannot renew their caller's access lifetime", () =>
327 Effect.gen(function* () {
328 const f = yield* fixture;
329 yield* Effect.gen(function* () {
330 const auth = yield* HostedAuth.HostedAuth;
331 const sessions = yield* SessionStore.SessionStore;
332 const { grant } = yield* nativeLogin(f);
333 const parent = yield* sessions.verify(grant.accessToken);
334 yield* TestClock.adjust(Duration.minutes(5));
335 const first = yield* auth.token(parent, ["archive:read"]);
336 expect(first.expiresAt.epochMilliseconds).toBe(grant.expiresAt.epochMilliseconds);
337 yield* TestClock.adjust(Duration.minutes(5));
338 const narrowed = yield* sessions.verify(first.token);
339 const second = yield* auth.token(narrowed, ["archive:read"]);
340 expect(second.expiresAt.epochMilliseconds).toBe(grant.expiresAt.epochMilliseconds);
341 yield* TestClock.adjust(Duration.minutes(6));
342 expect((yield* sessions.verifyActive(second.sessionId).pipe(Effect.result))._tag).toBe(
343 "Failure",
344 );
345 expect((yield* auth.token(narrowed, ["archive:read"]).pipe(Effect.result))._tag).toBe(
346 "Failure",
347 );
348 expect(f.refreshCalls()).toBe(0);
349 }).pipe(Effect.provide(f.layer));
350 }),
351 );
352
353 it.effect(
354 "binds only one active device to a source and prevents narrow grants from escalating",
355 () =>
356 Effect.gen(function* () {
357 const f = yield* fixture;
358 yield* Effect.gen(function* () {
359 const auth = yield* HostedAuth.HostedAuth;
360 const sessions = yield* SessionStore.SessionStore;
361 const first = (yield* nativeLogin(f)).grant;
362 const second = (yield* nativeLogin(f)).grant;
363 const source = EnvironmentId.make("existing-source-id");
364 const a = yield* sessions.verify(first.accessToken);
365 const b = yield* sessions.verify(second.accessToken);
366 yield* auth.bindEnvironment(a, source);
367 yield* auth.bindEnvironment(a, source);
368 const narrow = yield* auth.token(a, ["archive:read", "archive:write"]);
369 const narrowed = yield* sessions.verify(narrow.token);
370 yield* auth.authorizeEnvironment(narrowed, source);
371 expect(
372 (yield* auth
373 .authorizeEnvironment(narrowed, EnvironmentId.make("another-source"))
374 .pipe(Effect.flip)).reason,
375 ).toBe("forbidden");
376 expect((yield* auth.token(narrowed, ["providers:manage"]).pipe(Effect.flip)).reason).toBe(
377 "forbidden",
378 );
379 expect((yield* auth.revoke(narrowed, b.deviceId ?? "").pipe(Effect.flip)).reason).toBe(
380 "forbidden",
381 );
382 yield* auth.bindEnvironment(b, source);
383 yield* auth.authorizeEnvironment(b, source);
384 expect((yield* sessions.verify(first.accessToken).pipe(Effect.result))._tag).toBe(
385 "Failure",
386 );
387 expect((yield* auth.refresh(first.refreshToken).pipe(Effect.result))._tag).toBe(
388 "Failure",
389 );
390 }).pipe(Effect.provide(f.layer));
391 }),
392 );
393
394 it.effect(
395 "enforces canonical origin and CSRF on hosted cookies without trusting forwarded hosts",
396 () =>
397 Effect.gen(function* () {
398 const f = yield* fixture;
399 yield* Effect.gen(function* () {
400 const hosted = yield* HostedAuth.HostedAuth;
401 const auth = yield* EnvironmentAuth.EnvironmentAuth;
402 const sessions = yield* SessionStore.SessionStore;
403 const begun = yield* beginLogin(f, false);
404 const result = yield* hosted.callback({ ...begun, code: "code", client });
405 if (!result.session) return yield* Effect.die("Expected web session");
406 const session = yield* sessions.verify(result.session.token);
407 const account = yield* hosted.account(session);
408 const request = (origin?: string, csrf?: string, extra = {}) =>
409 HttpServerRequest.fromWeb(
410 new Request(`${settings.publicUrl}/api/hub/manage`, {
411 method: "POST",
412 headers: {
413 cookie: `${HostedAuthConfig.HOSTED_SESSION_COOKIE}=${result.session?.token}`,
414 ...(origin ? { origin } : {}),
415 ...(csrf ? { "x-csrf-token": csrf } : {}),
416 ...extra,
417 },
418 }),
419 );
420 for (const invalid of [
421 request(),
422 request(settings.publicUrl),
423 request("https://evil.example", account.csrfToken, {
424 "x-forwarded-host": "evil.example",
425 "sec-fetch-site": "same-origin",
426 }),
427 ])
428 expect((yield* auth.authenticateHttpRequest(invalid).pipe(Effect.result))._tag).toBe(
429 "Failure",
430 );
431 expect(
432 (yield* auth.authenticateHttpRequest(request(settings.publicUrl, account.csrfToken)))
433 .deviceId,
434 ).toBe(session.deviceId);
435 }).pipe(Effect.provide(f.layer));
436 }),
437 );
438 it.effect(
439 "expires a handoff after sixty seconds and requires interactive enrollment after the configured maximum age",
440 () =>
441 Effect.gen(function* () {
442 const f = yield* fixture;
443 yield* Effect.gen(function* () {
444 const auth = yield* HostedAuth.HostedAuth;
445 const begun = yield* beginLogin(f);
446 const callback = yield* auth.callback({ ...begun, code: "code", client });
447 const code = new URL(callback.url).searchParams.get("code") ?? "";
448 yield* TestClock.adjust(Duration.seconds(60));
449 expect(
450 (yield* auth.exchange({ code, codeVerifier: verifier }).pipe(Effect.flip)).reason,
451 ).toBe("credential");
452 const { grant } = yield* nativeLogin(f);
453 yield* TestClock.adjust(Duration.seconds(settings.maxInteractiveAgeSeconds));
454 expect((yield* auth.refresh(grant.refreshToken).pipe(Effect.flip)).reason).toBe(
455 "reauthentication",
456 );
457 }).pipe(Effect.provide(f.layer));
458 }),
459 );
460
461 it.effect("cannot mint access when a device is revoked during issuer renewal", () =>
462 Effect.gen(function* () {
463 const f = yield* fixture;
464 yield* Effect.gen(function* () {
465 const auth = yield* HostedAuth.HostedAuth;
466 const sessions = yield* SessionStore.SessionStore;
467 const { grant } = yield* nativeLogin(f);
468 const current = yield* sessions.verify(grant.accessToken);
469 yield* TestClock.adjust(Duration.seconds(61));
470 const entered = yield* Deferred.make<void>();
471 const release = yield* Deferred.make<void>();
472 f.pauseRefresh(entered, release);
473 const refreshing = yield* auth
474 .refresh(grant.refreshToken)
475 .pipe(Effect.result, Effect.forkScoped({ startImmediately: true }));
476 yield* Deferred.await(entered);
477 yield* auth.revoke(current, grant.deviceId);
478 yield* Deferred.succeed(release, undefined);
479 expect((yield* Fiber.join(refreshing))._tag).toBe("Failure");
480 expect(
481 Option.isNone(
482 yield* (yield* ServerSecretStore.ServerSecretStore).get(
483 `hosted-oidc-${grant.deviceId}`,
484 ),
485 ),
486 ).toBe(true);
487 }).pipe(Effect.provide(f.layer));
488 }),
489 );
490
491 it.effect("delivers secure cookies and JSON dates through the actual HTTP routes", () =>
492 Effect.gen(function* () {
493 const f = yield* fixture;
494 yield* Effect.gen(function* () {
495 const context = yield* Effect.context<
496 | HostedAuth.HostedAuth
497 | EnvironmentAuth.EnvironmentAuth
498 | HostedAuthConfig.HostedAuthConfig
499 >();
500 const app = yield* Effect.acquireRelease(
501 Effect.sync(() => HttpRouter.toWebHandler(HostedAuthHttp.layer, { disableLogger: true })),
502 (app) => Effect.promise(() => app.dispose()),
503 );
504 const request = (path: string, headers: Record<string, string> = {}, method = "GET") =>
505 Effect.promise(() =>
506 app.handler(new Request(`${settings.publicUrl}${path}`, { method, headers }), context),
507 );
508 const failed = yield* request("/api/auth/oidc/callback?error=access_denied");
509 expect(failed.status).toBe(303);
510 expect(failed.headers.get("location")).toBe(`${settings.publicUrl}/pair?signInError=1`);
511 expect(failed.headers.getSetCookie()[0]).toContain(
512 HostedAuthConfig.HOSTED_TRANSACTION_COOKIE,
513 );
514 const start = yield* request("/api/auth/oidc/start?returnTo=%2Fprojects");
515 expect(start.status).toBe(303);
516 expect(start.headers.getSetCookie()[0]).toContain("Max-Age=300");
517 const issuerUrl = new URL(start.headers.get("location") ?? "");
518 f.setNonce(issuerUrl.searchParams.get("nonce") ?? "");
519 const transactionCookie = start.headers.getSetCookie()[0]?.split(";")[0] ?? "";
520 const callback = yield* request(
521 `/api/auth/oidc/callback?code=issuer-code&state=${issuerUrl.searchParams.get("state")}`,
522 { cookie: transactionCookie },
523 );
524 expect(callback.status).toBe(303);
525 expect(callback.headers.get("location")).toBe(`${settings.publicUrl}/projects`);
526 const rawCookie =
527 callback.headers
528 .getSetCookie()
529 .find((value) => value.startsWith(HostedAuthConfig.HOSTED_SESSION_COOKIE + "=")) ?? "";
530 for (const attribute of ["Secure", "HttpOnly", "SameSite=Lax", "Path=/"])
531 expect(rawCookie).toContain(attribute);
532 expect(rawCookie).not.toContain("Domain=");
533 const cookie = rawCookie.split(";")[0] ?? "";
534 const accountResponse = yield* request("/api/auth/account", { cookie });
535 expect(accountResponse.status).toBe(200);
536 const account = yield* Effect.promise(() => accountResponse.json()).pipe(
537 Effect.flatMap(Schema.decodeUnknownEffect(Schema.toCodecJson(HostedAuthAccount))),
538 );
539 expect(
540 (yield* request(
541 "/api/auth/session/refresh",
542 { cookie, origin: settings.publicUrl },
543 "POST",
544 )).status,
545 ).toBe(401);
546 const renewed = yield* request(
547 "/api/auth/session/refresh",
548 { cookie, origin: settings.publicUrl, "x-csrf-token": account.csrfToken },
549 "POST",
550 );
551 expect(renewed.status).toBe(200);
552 expect(
553 renewed.headers
554 .getSetCookie()
555 .some((value) => value.startsWith(HostedAuthConfig.HOSTED_SESSION_COOKIE + "=")),
556 ).toBe(true);
557 expect((yield* request("/api/auth/account", { cookie })).status).toBe(401);
558 const rotatedCookie =
559 renewed.headers
560 .getSetCookie()
561 .find((value) => value.startsWith(HostedAuthConfig.HOSTED_SESSION_COOKIE + "="))
562 ?.split(";")[0] ?? "";
563 const otherTab = yield* request(
564 "/api/auth/session/refresh",
565 {
566 cookie: rotatedCookie,
567 origin: settings.publicUrl,
568 "x-csrf-token": account.csrfToken,
569 },
570 "POST",
571 );
572 expect(otherTab.status).toBe(200);
573 const currentCookie =
574 otherTab.headers
575 .getSetCookie()
576 .find((value) => value.startsWith(HostedAuthConfig.HOSTED_SESSION_COOKIE + "="))
577 ?.split(";")[0] ?? "";
578 const auth = yield* HostedAuth.HostedAuth;
579 const sessions = yield* SessionStore.SessionStore;
580 const another = yield* nativeLogin(f);
581 const anotherAccount = yield* auth.account(
582 yield* sessions.verify(another.grant.accessToken),
583 );
584 expect(
585 (yield* request(
586 "/api/auth/session/refresh",
587 {
588 cookie: currentCookie,
589 origin: settings.publicUrl,
590 "x-csrf-token": anotherAccount.csrfToken,
591 },
592 "POST",
593 )).status,
594 ).toBe(401);
595 const currentToken = decodeURIComponent(
596 currentCookie.slice(currentCookie.indexOf("=") + 1),
597 );
598 const currentSession = yield* sessions.verify(currentToken);
599 yield* auth.revoke(currentSession, account.deviceId);
600 expect((yield* request("/api/auth/account", { cookie: currentCookie })).status).toBe(401);
601 expect(
602 (yield* request(
603 "/api/auth/session/refresh",
604 {
605 cookie: currentCookie,
606 origin: settings.publicUrl,
607 "x-csrf-token": account.csrfToken,
608 },
609 "POST",
610 )).status,
611 ).toBe(401);
612 }).pipe(Effect.provide(f.layer));
613 }),
614 );
615
616 it.effect("keeps local emergency recovery independent of issuer availability", () =>
617 Effect.gen(function* () {
618 const f = yield* fixture;
619 yield* Effect.gen(function* () {
620 const auth = yield* HostedAuth.HostedAuth;
621 const sessions = yield* SessionStore.SessionStore;
622 const a = (yield* nativeLogin(f)).grant;
623 const b = (yield* nativeLogin(f)).grant;
624 const manual = yield* sessions.issue({ subject: "local-recovery" });
625 f.denyRefresh();
626 expect(yield* auth.emergencyRevokeAll).toBe(2);
627 for (const grant of [a, b])
628 expect((yield* sessions.verify(grant.accessToken).pipe(Effect.result))._tag).toBe(
629 "Failure",
630 );
631 expect((yield* sessions.verify(manual.token)).subject).toBe("local-recovery");
632 expect(yield* auth.emergencyRevokeAll).toBe(0);
633 }).pipe(Effect.provide(f.layer));
634 }),
635 );
636
637 it.effect("rejects configured SSO without an owner pin and disables SSO when unconfigured", () =>
638 Effect.gen(function* () {
639 const fs = yield* FileSystem.FileSystem;
640 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "knife-hosted-config-" });
641 const path = `${directory}/sso.json`;
642 yield* fs.writeFileString(
643 path,
644 encodeJson({
645 publicUrl: settings.publicUrl,
646 issuer: settings.issuer,
647 clientId: settings.clientId,
648 clientSecret: "test",
649 }),
650 );
651 const read = Effect.gen(function* () {
652 return yield* HostedAuthConfig.HostedAuthConfig;
653 });
654 const bad = yield* read.pipe(
655 Effect.provide(
656 HostedAuthConfig.layer.pipe(
657 Layer.provide(
658 ConfigProvider.layer(ConfigProvider.fromUnknown({ T3CODE_SSO_CONFIG: path })),
659 ),
660 ),
661 ),
662 Effect.flip,
663 );
664 expect(bad._tag).toBe("HostedAuthError");
665 const disabled = yield* read.pipe(
666 Effect.provide(
667 HostedAuthConfig.layer.pipe(
668 Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))),
669 ),
670 ),
671 );
672 expect(Option.isNone(disabled)).toBe(true);
673 }),
674 );
675 it.effect("re-enrolls an expired device without changing its existing environment identity", () =>
676 Effect.gen(function* () {
677 const f = yield* fixture;
678 yield* Effect.gen(function* () {
679 const auth = yield* HostedAuth.HostedAuth;
680 const sessions = yield* SessionStore.SessionStore;
681 const original = (yield* nativeLogin(f)).grant;
682 const id = EnvironmentId.make("stable-environment");
683 yield* auth.bindEnvironment(yield* sessions.verify(original.accessToken), id);
684 yield* TestClock.adjust(Duration.seconds(settings.maxInteractiveAgeSeconds));
685 const replacement = (yield* nativeLogin(f)).grant;
686 const session = yield* sessions.verify(replacement.accessToken);
687 yield* auth.bindEnvironment(session, id);
688 yield* auth.authorizeEnvironment(session, id);
689 expect(
690 (yield* auth.devices(session)).find((device) => device.deviceId === original.deviceId)
691 ?.revokedAt,
692 ).not.toBe(null);
693 }).pipe(Effect.provide(f.layer));
694 }),
695 );
696});