Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions apps/server/src/cloud/CliTokenManager.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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_<base64 of "clerk.example.test$">
Expand Down Expand Up @@ -164,6 +167,57 @@ const layerDeviceFlow = (server: DeviceFlowServer) =>
const tokenRequests = (requests: ReadonlyArray<RecordedTokenRequest>) =>
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* () {
Expand Down Expand Up @@ -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)));
}),
);
});
10 changes: 8 additions & 2 deletions apps/server/src/cloud/CliTokenManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -290,11 +294,12 @@ const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (
params: Record<string, string>,
) {
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* () {
Expand Down Expand Up @@ -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);
Expand Down
Loading