From d8be7e3536f77c0e0101de56fe0ca7161003a702 Mon Sep 17 00:00:00 2001 From: Kevin Rajan Date: Mon, 28 Sep 2026 00:10:05 -0500 Subject: [PATCH 1/4] [muse] fix(server): bound OAuth token HTTP calls with a 10s timeout Token-endpoint POSTs (refresh + authorization_code exchange) and the device authorization POST ran with no deadline. A wedged cloud endpoint hung the credential refresh under the manager's global semaphore, starving every other caller, or dead-ended login after the browser callback returned. Exchange calls now fail after 10s (the cloud HTTP family convention in this repo); refresh timeouts fall back to a fresh login via the existing orElseSucceed path, and the device-authorization POST maps to CloudCliAuthorizationError. AI-assisted: authored with AI assistance (Muse, Meta's Muse Spark) under the contributor's direction. --- apps/server/src/cloud/CliTokenManager.test.ts | 98 +++++++++++++++++++ apps/server/src/cloud/CliTokenManager.ts | 12 +++ 2 files changed, 110 insertions(+) diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index 37f645cbd4ffa..2597cea8286df 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -15,6 +16,8 @@ import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ExternalLauncher from "../process/externalLauncher.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; // pk_test_ @@ -324,4 +327,99 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => assert.isTrue(isAuthorizationError(result)); }), ); + + // An HTTP client whose requests never complete. + const hangingHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.never), + ); + + const StoredTokenJson = Schema.fromJsonString( + Schema.Struct({ + accessToken: Schema.String, + refreshToken: Schema.String, + expiresAtEpochMs: Schema.Number, + }), + ); + const encodeStoredToken = Schema.encodeSync(StoredTokenJson); + + it.effect("fails instead of hanging when the device authorization endpoint stalls", () => + Effect.gen(function* () { + const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( + Effect.provide(hangingHttpClientLayer), + provideTestEnv, + Effect.flip, + Effect.forkChild, + ); + yield* TestClock.adjust(Duration.seconds(10)); + const result = yield* Fiber.join(fiber); + + assert.isTrue(isAuthorizationError(result)); + }), + ); + + it.effect("fails the refresh instead of hanging when the token endpoint stalls", () => + Effect.gen(function* () { + const stored = new Map(); + stored.set( + "cloud-cli-oauth-token", + new TextEncoder().encode( + encodeStoredToken({ + accessToken: "expired-access", + refreshToken: "refresh-token-1", + expiresAtEpochMs: 1, + }), + ), + ); + const secrets = ServerSecretStore.ServerSecretStore.of({ + get: (name) => + Effect.succeed( + stored.has(name) ? Option.some(stored.get(name)!) : Option.none(), + ), + set: (name, value) => + Effect.sync(() => { + stored.set(name, value); + }), + create: (name, value) => + Effect.sync(() => { + stored.set(name, value); + }), + getOrCreateRandom: (_name, bytes) => Effect.succeed(new Uint8Array(bytes)), + remove: (name) => + Effect.sync(() => { + stored.delete(name); + }), + }); + const queue = yield* Queue.make(); + const launcher = ExternalLauncher.ExternalLauncher.of({ + resolveAvailableEditors: () => Effect.succeed([]), + resolveFileManagerRevealKind: () => Effect.succeed(undefined), + launchBrowser: () => Effect.void, + launchEditor: () => Effect.void, + }); + const crypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(7), + digest: (_algorithm, data) => Effect.succeed(data), + }); + const managerLayer = Layer.provide( + CliTokenManager.layer, + Layer.mergeAll( + hangingHttpClientLayer, + Layer.succeed(Crypto.Crypto, crypto), + Layer.succeed(ServerSecretStore.ServerSecretStore, secrets), + Layer.succeed(Terminal.Terminal, makeTestTerminal(queue)), + Layer.succeed(ExternalLauncher.ExternalLauncher, launcher), + ), + ); + + const fiber = yield* Effect.gen(function* () { + const manager = yield* CliTokenManager.CloudCliTokenManager; + return yield* manager.getExisting; + }).pipe(Effect.provide(managerLayer), provideTestEnv, Effect.flip, Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(10)); + const result = yield* Fiber.join(fiber); + + assert.instanceOf(result, CliTokenManager.CloudCliCredentialRefreshError); + }), + ); }); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 2a666651a9a5b..fad303484031b 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -44,6 +44,11 @@ const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL = Duration.seconds(5); // RFC 8628 §3.5: a slow_down response means "add 5 seconds to the interval". const DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT = Duration.seconds(5); +// Upper bound for a single OAuth HTTP round trip (token endpoint, device +// authorization endpoint). A wedged cloud endpoint would otherwise hang the +// credential refresh under the manager's global semaphore — starving every +// other caller — or dead-end login after the browser callback returned. +const CLOUD_CLI_OAUTH_HTTP_TIMEOUT = Duration.seconds(10); const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { @@ -293,6 +298,9 @@ const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( HttpClientRequest.bodyUrlParams(params), httpClient.execute, + // The caller maps a timeout into its own failure path: refresh falls back + // to a fresh login, login reports an authorization error. + Effect.timeout(CLOUD_CLI_OAUTH_HTTP_TIMEOUT), ); return yield* readTokenResponse(response, params); }); @@ -392,6 +400,10 @@ export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_author scope: metadata.scopes.join(" "), }), httpClient.execute, + Effect.timeout(CLOUD_CLI_OAUTH_HTTP_TIMEOUT), + Effect.catchTag("TimeoutError", (cause) => + Effect.fail(new CloudCliAuthorizationError({ cause })), + ), Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)), ); // Clerk's advertised lifetime and interval are authoritative. From 0c6e1cfa54548800551974d4e24c2d25fcf48889 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:03:08 -0500 Subject: [PATCH 2/4] fix(cloud): include OAuth response bodies in HTTP deadlines --- apps/server/src/cloud/CliTokenManager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index fad303484031b..bbb8e9904ec50 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -295,14 +295,14 @@ const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( params: Record, ) { const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); - const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( + return yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( HttpClientRequest.bodyUrlParams(params), httpClient.execute, + Effect.flatMap((response) => readTokenResponse(response, params)), // The caller maps a timeout into its own failure path: refresh falls back // to a fresh login, login reports an authorization error. Effect.timeout(CLOUD_CLI_OAUTH_HTTP_TIMEOUT), ); - return yield* readTokenResponse(response, params); }); const makePkceRequest = Effect.gen(function* () { @@ -400,11 +400,11 @@ export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_author scope: metadata.scopes.join(" "), }), httpClient.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)), Effect.timeout(CLOUD_CLI_OAUTH_HTTP_TIMEOUT), Effect.catchTag("TimeoutError", (cause) => Effect.fail(new CloudCliAuthorizationError({ cause })), ), - Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)), ); // Clerk's advertised lifetime and interval are authoritative. const expiresIn = Duration.seconds(authorization.expires_in); From 7834a50370b762f4b6c850fbd1b2a1df271c44eb Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:03:10 -0500 Subject: [PATCH 3/4] test(cloud): cover OAuth bodies stalled after response headers --- apps/server/src/cloud/CliTokenManager.test.ts | 151 +++++++++++------- 1 file changed, 94 insertions(+), 57 deletions(-) diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index 2597cea8286df..a365bacf6ccc0 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -334,6 +334,26 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => HttpClient.make(() => Effect.never), ); + // HTTP 200 headers followed by an incomplete body that never closes. + const bodyStallsAfterHeadersLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{")); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ), + ), + ); + const StoredTokenJson = Schema.fromJsonString( Schema.Struct({ accessToken: Schema.String, @@ -358,68 +378,85 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => }), ); - it.effect("fails the refresh instead of hanging when the token endpoint stalls", () => + it.effect("fails if the device authorization body stalls after headers", () => Effect.gen(function* () { - const stored = new Map(); - stored.set( - "cloud-cli-oauth-token", - new TextEncoder().encode( - encodeStoredToken({ - accessToken: "expired-access", - refreshToken: "refresh-token-1", - expiresAtEpochMs: 1, - }), - ), + const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( + Effect.provide(bodyStallsAfterHeadersLayer), + provideTestEnv, + Effect.flip, + Effect.forkChild, ); - const secrets = ServerSecretStore.ServerSecretStore.of({ - get: (name) => - Effect.succeed( - stored.has(name) ? Option.some(stored.get(name)!) : Option.none(), + yield* TestClock.adjust(Duration.seconds(11)); + const result = yield* Fiber.join(fiber); + + assert.isTrue(isAuthorizationError(result)); + }), + ); + + it.effect("fails refresh if the token endpoint stalls before or after headers", () => + Effect.gen(function* () { + for (const httpClientLayer of [hangingHttpClientLayer, bodyStallsAfterHeadersLayer]) { + const stored = new Map(); + stored.set( + "cloud-cli-oauth-token", + new TextEncoder().encode( + encodeStoredToken({ + accessToken: "expired-access", + refreshToken: "refresh-token-1", + expiresAtEpochMs: 1, + }), ), - set: (name, value) => - Effect.sync(() => { - stored.set(name, value); - }), - create: (name, value) => - Effect.sync(() => { - stored.set(name, value); - }), - getOrCreateRandom: (_name, bytes) => Effect.succeed(new Uint8Array(bytes)), - remove: (name) => - Effect.sync(() => { - stored.delete(name); - }), - }); - const queue = yield* Queue.make(); - const launcher = ExternalLauncher.ExternalLauncher.of({ - resolveAvailableEditors: () => Effect.succeed([]), - resolveFileManagerRevealKind: () => Effect.succeed(undefined), - launchBrowser: () => Effect.void, - launchEditor: () => Effect.void, - }); - const crypto = Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(7), - digest: (_algorithm, data) => Effect.succeed(data), - }); - const managerLayer = Layer.provide( - CliTokenManager.layer, - Layer.mergeAll( - hangingHttpClientLayer, - Layer.succeed(Crypto.Crypto, crypto), - Layer.succeed(ServerSecretStore.ServerSecretStore, secrets), - Layer.succeed(Terminal.Terminal, makeTestTerminal(queue)), - Layer.succeed(ExternalLauncher.ExternalLauncher, launcher), - ), - ); + ); + const secrets = ServerSecretStore.ServerSecretStore.of({ + get: (name) => + Effect.succeed( + stored.has(name) ? Option.some(stored.get(name)!) : Option.none(), + ), + set: (name, value) => + Effect.sync(() => { + stored.set(name, value); + }), + create: (name, value) => + Effect.sync(() => { + stored.set(name, value); + }), + getOrCreateRandom: (_name, bytes) => Effect.succeed(new Uint8Array(bytes)), + remove: (name) => + Effect.sync(() => { + stored.delete(name); + }), + }); + const queue = yield* Queue.make(); + const launcher = ExternalLauncher.ExternalLauncher.of({ + resolveAvailableEditors: () => Effect.succeed([]), + resolveFileManagerRevealKind: () => Effect.succeed(undefined), + launchBrowser: () => Effect.void, + launchEditor: () => Effect.void, + }); + const crypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(7), + digest: (_algorithm, data) => Effect.succeed(data), + }); + const managerLayer = Layer.provide( + CliTokenManager.layer, + Layer.mergeAll( + httpClientLayer, + Layer.succeed(Crypto.Crypto, crypto), + Layer.succeed(ServerSecretStore.ServerSecretStore, secrets), + Layer.succeed(Terminal.Terminal, makeTestTerminal(queue)), + Layer.succeed(ExternalLauncher.ExternalLauncher, launcher), + ), + ); - const fiber = yield* Effect.gen(function* () { - const manager = yield* CliTokenManager.CloudCliTokenManager; - return yield* manager.getExisting; - }).pipe(Effect.provide(managerLayer), provideTestEnv, Effect.flip, Effect.forkChild); - yield* TestClock.adjust(Duration.seconds(10)); - const result = yield* Fiber.join(fiber); + const fiber = yield* Effect.gen(function* () { + const manager = yield* CliTokenManager.CloudCliTokenManager; + return yield* manager.getExisting; + }).pipe(Effect.provide(managerLayer), provideTestEnv, Effect.flip, Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(10)); + const result = yield* Fiber.join(fiber); - assert.instanceOf(result, CliTokenManager.CloudCliCredentialRefreshError); + assert.instanceOf(result, CliTokenManager.CloudCliCredentialRefreshError); + } }), ); }); From e44edb761080347eefe555f1d4a4fe102eaed6e4 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:09:01 -0500 Subject: [PATCH 4/4] style(cloud): use catchTags for OAuth timeout --- apps/server/src/cloud/CliTokenManager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index bbb8e9904ec50..fb1369a21f9d2 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -402,9 +402,9 @@ export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_author httpClient.execute, Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)), Effect.timeout(CLOUD_CLI_OAUTH_HTTP_TIMEOUT), - Effect.catchTag("TimeoutError", (cause) => - Effect.fail(new CloudCliAuthorizationError({ cause })), - ), + Effect.catchTags({ + TimeoutError: (cause) => Effect.fail(new CloudCliAuthorizationError({ cause })), + }), ); // Clerk's advertised lifetime and interval are authoritative. const expiresIn = Duration.seconds(authorization.expires_in);