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 @@...@@ -1,5 +1,6 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";
3import * as ConfigProvider from "effect/ConfigProvider";4import * as ConfigProvider from "effect/ConfigProvider";
4import * as Deferred from "effect/Deferred";5import * as Deferred from "effect/Deferred";
5import * as Duration from "effect/Duration";6import * as Duration from "effect/Duration";
...@@ -15,6 +16,8 @@ import * as TestClock from "effect/testing/TestClock";...@@ -15,6 +16,8 @@ import * as TestClock from "effect/testing/TestClock";
15import * as HttpClient from "effect/http/HttpClient";16import * as HttpClient from "effect/http/HttpClient";
16import * as HttpClientResponse from "effect/http/HttpClientResponse";17import * as HttpClientResponse from "effect/http/HttpClientResponse";
1718
19import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
20import * as ExternalLauncher from "../process/externalLauncher.ts";
18import * as CliTokenManager from "./CliTokenManager.ts";21import * as CliTokenManager from "./CliTokenManager.ts";
1922
20// pk_test_<base64 of "clerk.example.test$">23// pk_test_<base64 of "clerk.example.test$">
...@@ -164,6 +167,57 @@ const layerDeviceFlow = (server: DeviceFlowServer) =>...@@ -164,6 +167,57 @@ const layerDeviceFlow = (server: DeviceFlowServer) =>
164const tokenRequests = (requests: ReadonlyArray<RecordedTokenRequest>) =>167const tokenRequests = (requests: ReadonlyArray<RecordedTokenRequest>) =>
165 requests.filter((request) => request.url.endsWith("/oauth/token"));168 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
167it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => {221it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => {
168 it.effect("requests a device code, shows it, and polls until Clerk grants the token", () =>222 it.effect("requests a device code, shows it, and polls until Clerk grants the token", () =>
169 Effect.gen(function* () {223 Effect.gen(function* () {
...@@ -322,4 +376,18 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) =>...@@ -322,4 +376,18 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) =>
322 assert.isTrue(isAuthorizationError(result));376 assert.isTrue(isAuthorizationError(result));
323 }),377 }),
324 );378 );
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 );
325});393});
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";...@@ -44,6 +44,10 @@ const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
44const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL = Duration.seconds(5);44const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL = Duration.seconds(5);
45// RFC 8628 §3.5: a slow_down response means "add 5 seconds to the interval".45// RFC 8628 §3.5: a slow_down response means "add 5 seconds to the interval".
46const DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT = Duration.seconds(5);46const 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);
47const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;51const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;
4852
49function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string {53function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string {
...@@ -290,11 +294,12 @@ const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (...@@ -290,11 +294,12 @@ const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (
290 params: Record<string, string>,294 params: Record<string, string>,
291) {295) {
292 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);296 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(
294 HttpClientRequest.bodyUrlParams(params),298 HttpClientRequest.bodyUrlParams(params),
295 httpClient.execute,299 httpClient.execute,
300 Effect.flatMap((response) => readTokenResponse(response, params)),
301 Effect.timeout(OAUTH_REQUEST_TIMEOUT),
296 );302 );
297 return yield* readTokenResponse(response, params);
298});303});
299304
300const makePkceRequest = Effect.gen(function* () {305const makePkceRequest = Effect.gen(function* () {
...@@ -393,6 +398,7 @@ export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_author...@@ -393,6 +398,7 @@ export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_author
393 }),398 }),
394 httpClient.execute,399 httpClient.execute,
395 Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)),400 Effect.flatMap(HttpClientResponse.schemaBodyJson(DeviceAuthorizationResponse)),
401 Effect.timeout(OAUTH_REQUEST_TIMEOUT),
396 );402 );
397 // Clerk's advertised lifetime and interval are authoritative.403 // Clerk's advertised lifetime and interval are authoritative.
398 const expiresIn = Duration.seconds(authorization.expires_in);404 const expiresIn = Duration.seconds(authorization.expires_in);