From 315850c1897424fa2354a91330889e52cde7568f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 01:28:29 -0700 Subject: [PATCH] fix(server): stalled OAuth token requests no longer hang credential calls Token and device-authorization requests had no timeout. If the endpoint stalled, even after sending headers, a refresh held the credential semaphore forever and blocked every later credential call. Bound each request, including the body read, to 10 seconds. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cloud/CliTokenManager.test.ts | 68 +++++++++++++++++++ apps/server/src/cloud/CliTokenManager.ts | 10 ++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index c0fb59026a76..83727d3fc026 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -15,6 +16,8 @@ import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ExternalLauncher from "../process/externalLauncher.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; // pk_test_ @@ -164,6 +167,57 @@ const layerDeviceFlow = (server: DeviceFlowServer) => const tokenRequests = (requests: ReadonlyArray) => requests.filter((request) => request.url.endsWith("/oauth/token")); +// The endpoint sends 200 headers, then the JSON body never finishes. +const layerStalledBody = 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 expiredStoredToken = new TextEncoder().encode( + JSON.stringify({ accessToken: "expired", refreshToken: "refresh-token-1", expiresAtEpochMs: 0 }), +); + +it.effect("a stalled token refresh fails and releases the credential lock", () => + Effect.gen(function* () { + const manager = yield* CliTokenManager.CloudCliTokenManager; + const refresh = yield* manager.getExisting.pipe(Effect.flip, Effect.forkChild); + const hasCredential = yield* manager.hasCredential.pipe(Effect.forkChild); + + yield* TestClock.adjust(Duration.seconds(10)); + + assert.instanceOf(yield* Fiber.join(refresh), CliTokenManager.CloudCliCredentialRefreshError); + assert.isTrue(yield* Fiber.join(hasCredential)); + }).pipe( + Effect.provide( + CliTokenManager.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerStalledBody, + Layer.mock(ServerSecretStore.ServerSecretStore)({ + get: () => Effect.succeedSome(expiredStoredToken), + }), + Layer.mock(ExternalLauncher.ExternalLauncher)({}), + NodeServices.layer, + ), + ), + ), + ), + provideTestEnv, + ), +); + it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => { it.effect("requests a device code, shows it, and polls until Clerk grants the token", () => Effect.gen(function* () { @@ -322,4 +376,18 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => assert.isTrue(isAuthorizationError(result)); }), ); + + it.effect("fails instead of hanging when the device authorization response stalls", () => + Effect.gen(function* () { + const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe( + Effect.provide(layerStalledBody), + provideTestEnv, + Effect.flip, + Effect.forkChild, + ); + yield* TestClock.adjust(Duration.seconds(10)); + + assert.isTrue(Cause.isTimeoutError(yield* Fiber.join(fiber))); + }), + ); }); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 475f9aa9caee..6a0223a4121f 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -44,6 +44,10 @@ 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); +// Bounds one token or device-authorization request, including reading the +// body. A stalled refresh holds the manager's semaphore and blocks every +// other credential call. +const OAUTH_REQUEST_TIMEOUT = Duration.seconds(10); const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { @@ -290,11 +294,12 @@ 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)), + Effect.timeout(OAUTH_REQUEST_TIMEOUT), ); - return yield* readTokenResponse(response, params); }); const makePkceRequest = Effect.gen(function* () { @@ -393,6 +398,7 @@ export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_author }), httpClient.execute, Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)), + Effect.timeout(OAUTH_REQUEST_TIMEOUT), ); // Clerk's advertised lifetime and interval are authoritative. const expiresIn = Duration.seconds(authorization.expires_in);