| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import { EnvironmentId, HostedAuthAccount } from "@t3tools/contracts"; |
| 4 | import { exportJWK, generateKeyPair, SignJWT, type JWTPayload } from "jose"; |
| 5 | import * as Crypto from "effect/Crypto"; |
| 6 | import * as Base64Url from "effect/encoding/Base64Url"; |
| 7 | import * as DateTime from "effect/DateTime"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 10 | import * as Deferred from "effect/Deferred"; |
| 11 | import * as Duration from "effect/Duration"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Fiber from "effect/Fiber"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Option from "effect/Option"; |
| 16 | import * as Redacted from "effect/Redacted"; |
| 17 | import * as Schema from "effect/Schema"; |
| 18 | import * as TestClock from "effect/testing/TestClock"; |
| 19 | import { HttpClient, HttpClientResponse, HttpRouter, HttpServerRequest } from "effect/http"; |
| 20 | import * as ServerConfig from "../config.ts"; |
| 21 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 22 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; |
| 23 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 24 | import * as HostedAuth from "./HostedAuth.ts"; |
| 25 | import * as HostedAuthConfig from "./HostedAuthConfig.ts"; |
| 26 | import * as HostedAuthHttp from "./HostedAuthHttp.ts"; |
| 27 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 28 | import * as SessionStore from "./SessionStore.ts"; |
| 29 | |
| 30 | const 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 | }; |
| 39 | const client = { label: "Test computer", deviceType: "desktop" } as const; |
| 40 | const verifier = "v".repeat(43); |
| 41 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 42 | |
| 43 | const 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 | |
| 147 | const 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 | }); |
| 171 | const 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 | |
| 185 | it.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 | }); |