authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 01:38:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 01:38:57-07:00
logf372c0061f7678da94853110da06e821a3af7c83
tree1becdbbbf524b8ce175a600586262b5a97157d48
parent233e70b3b2b42f52f12a8ad48089f648a1cb143d
signature Signed by PGP key B5690EEEBB952194

fix(server): stalled OAuth token requests no longer block credential calls (#19321)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 76 insertions(+), 2 deletions(-)

apps/server/src/cloud/CliTokenManager.test.ts+68
......@@ -1,5 +1,6 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { assert, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";
34import * as ConfigProvider from "effect/ConfigProvider";
45import * as Deferred from "effect/Deferred";
56import * as Duration from "effect/Duration";
......@@ -15,6 +16,8 @@ import * as TestClock from "effect/testing/TestClock";
1516import * as HttpClient from "effect/http/HttpClient";
1617import * as HttpClientResponse from "effect/http/HttpClientResponse";
1718
19import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
20import * as ExternalLauncher from "../process/externalLauncher.ts";
1821import * as CliTokenManager from "./CliTokenManager.ts";
1922
2023// pk_test_<base64 of "clerk.example.test$">
......@@ -164,6 +167,57 @@ const layerDeviceFlow = (server: DeviceFlowServer) =>
164167const tokenRequests = (requests: ReadonlyArray<RecordedTokenRequest>) =>
165168 requests.filter((request) => request.url.endsWith("/oauth/token"));
166169
170// The endpoint sends 200 headers, then the JSON body never finishes.
171const layerStalledBody = Layer.succeed(
172 HttpClient.HttpClient,
173 HttpClient.make((request) =>
174 Effect.succeed(
175 HttpClientResponse.fromWeb(
176 request,
177 new Response(
178 new ReadableStream({
179 start: (controller) => controller.enqueue(new TextEncoder().encode("{")),
180 }),
181 { status: 200, headers: { "content-type": "application/json" } },
182 ),
183 ),
184 ),
185 ),
186);
187
188const expiredStoredToken = new TextEncoder().encode(
189 JSON.stringify({ accessToken: "expired", refreshToken: "refresh-token-1", expiresAtEpochMs: 0 }),
190);
191
192it.effect("a stalled token refresh fails and releases the credential lock", () =>
193 Effect.gen(function* () {
194 const manager = yield* CliTokenManager.CloudCliTokenManager;
195 const refresh = yield* manager.getExisting.pipe(Effect.flip, Effect.forkChild);
196 const hasCredential = yield* manager.hasCredential.pipe(Effect.forkChild);
197
198 yield* TestClock.adjust(Duration.seconds(10));
199
200 assert.instanceOf(yield* Fiber.join(refresh), CliTokenManager.CloudCliCredentialRefreshError);
201 assert.isTrue(yield* Fiber.join(hasCredential));
202 }).pipe(
203 Effect.provide(
204 CliTokenManager.layer.pipe(
205 Layer.provide(
206 Layer.mergeAll(
207 layerStalledBody,
208 Layer.mock(ServerSecretStore.ServerSecretStore)({
209 get: () => Effect.succeedSome(expiredStoredToken),
210 }),
211 Layer.mock(ExternalLauncher.ExternalLauncher)({}),
212 NodeServices.layer,
213 ),
214 ),
215 ),
216 ),
217 provideTestEnv,
218 ),
219);
220
167221it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => {
168222 it.effect("requests a device code, shows it, and polls until Clerk grants the token", () =>
169223 Effect.gen(function* () {
......@@ -322,4 +376,18 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) =>
322376 assert.isTrue(isAuthorizationError(result));
323377 }),
324378 );
379
380 it.effect("fails instead of hanging when the device authorization response stalls", () =>
381 Effect.gen(function* () {
382 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
383 Effect.provide(layerStalledBody),
384 provideTestEnv,
385 Effect.flip,
386 Effect.forkChild,
387 );
388 yield* TestClock.adjust(Duration.seconds(10));
389
390 assert.isTrue(Cause.isTimeoutError(yield* Fiber.join(fiber)));
391 }),
392 );
325393});
apps/server/src/cloud/CliTokenManager.ts+8-2
......@@ -44,6 +44,10 @@ const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
4444const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL = Duration.seconds(5);
4545// RFC 8628 §3.5: a slow_down response means "add 5 seconds to the interval".
4646const DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT = Duration.seconds(5);
47// Bounds one token or device-authorization request, including reading the
48// body. A stalled refresh holds the manager's semaphore and blocks every
49// other credential call.
50const OAUTH_REQUEST_TIMEOUT = Duration.seconds(10);
4751const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;
4852
4953function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string {
......@@ -290,11 +294,12 @@ const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (
290294 params: Record<string, string>,
291295) {
292296 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
293 const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
297 return yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
294298 HttpClientRequest.bodyUrlParams(params),
295299 httpClient.execute,
300 Effect.flatMap((response) => readTokenResponse(response, params)),
301 Effect.timeout(OAUTH_REQUEST_TIMEOUT),
296302 );
297 return yield* readTokenResponse(response, params);
298303});
299304
300305const makePkceRequest = Effect.gen(function* () {
......@@ -393,6 +398,7 @@ export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_author
393398 }),
394399 httpClient.execute,
395400 Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)),
401 Effect.timeout(OAUTH_REQUEST_TIMEOUT),
396402 );
397403 // Clerk's advertised lifetime and interval are authoritative.
398404 const expiresIn = Duration.seconds(authorization.expires_in);