1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Cause from "effect/Cause";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Deferred from "effect/Deferred";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Base64Url from "effect/encoding/Base64Url";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Queue from "effect/Queue";
13import * as Schema from "effect/Schema";
14import * as Terminal from "effect/Terminal";
15import * as TestClock from "effect/testing/TestClock";
16import * as HttpClient from "effect/http/HttpClient";
17import * as HttpClientResponse from "effect/http/HttpClientResponse";
18
19import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
20import * as ExternalLauncher from "../process/externalLauncher.ts";
21import * as CliTokenManager from "./CliTokenManager.ts";
22
23// pk_test_<base64 of "clerk.example.test$">
24const TEST_ENV = {
25 T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_Y2xlcmsuZXhhbXBsZS50ZXN0JA==",
26 T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_test",
27 T3CODE_HOSTED_APP_URL: "https://hosted.example.test",
28};
29
30interface RecordedTokenRequest {
31 readonly url: string;
32 readonly params: URLSearchParams;
33}
34
35// A JWT whose payload claims { email: "theo@example.test" } (signature is not
36// verified — the CLI only reads the claim to display the connected account).
37const TestIdTokenHeaderJson = Schema.fromJsonString(Schema.Struct({ alg: Schema.Literal("none") }));
38const TestIdTokenPayloadJson = Schema.fromJsonString(Schema.Struct({ email: Schema.String }));
39const encodeTestIdTokenHeader = Schema.encodeSync(TestIdTokenHeaderJson);
40const encodeTestIdTokenPayload = Schema.encodeSync(TestIdTokenPayloadJson);
41const idTokenWithEmail = (() => {
42 const header = Base64Url.encode(encodeTestIdTokenHeader({ alg: "none" }));
43 const payload = Base64Url.encode(encodeTestIdTokenPayload({ email: "theo@example.test" }));
44 return `${header}.${payload}.`;
45})();
46
47const TestTokenResponseJson = Schema.fromJsonString(
48 Schema.Struct({
49 access_token: Schema.String,
50 refresh_token: Schema.String,
51 id_token: Schema.String,
52 expires_in: Schema.Number,
53 token_type: Schema.String,
54 }),
55);
56const encodeTestTokenResponse = Schema.encodeSync(TestTokenResponseJson);
57
58const provideTestEnv = Effect.provide(
59 ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })),
60);
61
62const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError);
63
64const makeTestTerminal = (queue: Queue.Queue<Terminal.UserInput>) =>
65 Terminal.make({
66 columns: Effect.succeed(80),
67 rows: Effect.succeed(24),
68 readInput: Effect.succeed(Queue.asDequeue(queue)),
69 readLine: Effect.never,
70 display: () => Effect.void,
71 });
72
73const userInput = (name: string): Terminal.UserInput => ({
74 input: Option.some(name),
75 key: { name, ctrl: false, meta: false, shift: name !== name.toLowerCase() },
76});
77
78it.effect("opens the browser on Enter and switches the active flow on H", () =>
79 Effect.gen(function* () {
80 const queue = yield* Queue.make<Terminal.UserInput>();
81 yield* Queue.offerAll(queue, [userInput("enter"), userInput("H")]);
82 const opened: Array<string> = [];
83
84 const result = yield* CliTokenManager.waitForLoopbackAuthorization({
85 authorizationUrl: "https://clerk.example.test/authorize",
86 callback: Effect.never,
87 terminal: makeTestTerminal(queue),
88 launchBrowser: (url) =>
89 Effect.sync(() => {
90 opened.push(url);
91 }),
92 });
93
94 assert.deepEqual(opened, ["https://clerk.example.test/authorize"]);
95 assert.deepEqual(result, { _tag: "HeadlessRequested" });
96 }),
97);
98
99it.effect("finishes normally when the browser callback wins", () =>
100 Effect.gen(function* () {
101 const queue = yield* Queue.make<Terminal.UserInput>();
102 const callback = yield* Deferred.make<string>();
103 yield* Deferred.succeed(callback, "clerk-code-123");
104
105 const result = yield* CliTokenManager.waitForLoopbackAuthorization({
106 authorizationUrl: "https://clerk.example.test/authorize",
107 callback: Deferred.await(callback),
108 terminal: makeTestTerminal(queue),
109 launchBrowser: () => Effect.die("browser launch should not run"),
110 });
111
112 assert.deepEqual(result, { _tag: "AuthorizationCode", code: "clerk-code-123" });
113 }),
114);
115
116interface DeviceFlowServer {
117 readonly requests: Array<RecordedTokenRequest>;
118 /** Token endpoint replies, consumed in order; the last one repeats. */
119 readonly tokenReplies: Array<{ readonly status: number; readonly body: string }>;
120}
121
122const DEVICE_AUTHORIZATION_BODY = JSON.stringify({
123 device_code: "device-code-1",
124 user_code: "BCDF-GHJK",
125 verification_uri: "https://accounts.example.test/device",
126 verification_uri_complete: "https://accounts.example.test/device?user_code=BCDF-GHJK",
127 expires_in: 600,
128 interval: 5,
129});
130
131const oauthError = (error: string) => ({ status: 400, body: JSON.stringify({ error }) });
132const tokenGranted = {
133 status: 200,
134 body: encodeTestTokenResponse({
135 access_token: "access-token-1",
136 refresh_token: "refresh-token-1",
137 id_token: idTokenWithEmail,
138 expires_in: 3600,
139 token_type: "bearer",
140 }),
141};
142
143const layerDeviceFlow = (server: DeviceFlowServer) =>
144 Layer.succeed(
145 HttpClient.HttpClient,
146 HttpClient.make((request) =>
147 Effect.sync(() => {
148 const body =
149 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
150 server.requests.push({ url: request.url, params: new URLSearchParams(body) });
151 const reply = request.url.endsWith("/oauth/device_authorization")
152 ? { status: 200, body: DEVICE_AUTHORIZATION_BODY }
153 : ((server.tokenReplies.length > 1
154 ? server.tokenReplies.shift()
155 : server.tokenReplies[0]) ?? oauthError("invalid_grant"));
156 return HttpClientResponse.fromWeb(
157 request,
158 new Response(reply.body, {
159 status: reply.status,
160 headers: { "content-type": "application/json" },
161 }),
162 );
163 }),
164 ),
165 );
166
167const tokenRequests = (requests: ReadonlyArray<RecordedTokenRequest>) =>
168 requests.filter((request) => request.url.endsWith("/oauth/token"));
169
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
221it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => {
222 it.effect("requests a device code, shows it, and polls until Clerk grants the token", () =>
223 Effect.gen(function* () {
224 const server: DeviceFlowServer = {
225 requests: [],
226 tokenReplies: [oauthError("authorization_pending"), tokenGranted],
227 };
228 const prompts: Array<CliTokenManager.DeviceAuthorizationPrompt> = [];
229
230 const fiber = yield* CliTokenManager.deviceAuthorizationLogin((prompt) =>
231 Effect.sync(() => {
232 prompts.push(prompt);
233 }),
234 ).pipe(Effect.provide(layerDeviceFlow(server)), provideTestEnv, Effect.forkChild);
235
236 yield* TestClock.adjust(Duration.seconds(10));
237 const { token, identity } = yield* Fiber.join(fiber);
238
239 assert.deepEqual(prompts, [
240 {
241 verificationUri: "https://accounts.example.test/device",
242 verificationUriComplete: "https://accounts.example.test/device?user_code=BCDF-GHJK",
243 userCode: "BCDF-GHJK",
244 expiresIn: Duration.seconds(600),
245 },
246 ]);
247 assert.equal(token.accessToken, "access-token-1");
248 assert.equal(token.refreshToken, "refresh-token-1");
249 assert.equal(token.identity, "theo@example.test");
250 assert.equal(identity, "theo@example.test");
251
252 const authorization = server.requests[0]!;
253 assert.equal(authorization.url, "https://clerk.example.test/oauth/device_authorization");
254 assert.equal(authorization.params.get("client_id"), "oauth_client_test");
255 assert.equal(authorization.params.get("scope"), "openid profile email offline_access");
256
257 const polls = tokenRequests(server.requests);
258 assert.lengthOf(polls, 2);
259 for (const poll of polls) {
260 assert.equal(poll.url, "https://clerk.example.test/oauth/token");
261 assert.equal(poll.params.get("grant_type"), "urn:ietf:params:oauth:grant-type:device_code");
262 assert.equal(poll.params.get("device_code"), "device-code-1");
263 assert.equal(poll.params.get("client_id"), "oauth_client_test");
264 }
265 }),
266 );
267
268 it.effect("waits the advertised interval between polls and backs off on slow_down", () =>
269 Effect.gen(function* () {
270 const server: DeviceFlowServer = {
271 requests: [],
272 tokenReplies: [oauthError("slow_down"), oauthError("authorization_pending")],
273 };
274
275 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
276 Effect.provide(layerDeviceFlow(server)),
277 provideTestEnv,
278 Effect.forkChild,
279 );
280
281 yield* TestClock.adjust(Duration.seconds(4));
282 assert.lengthOf(tokenRequests(server.requests), 0);
283 yield* TestClock.adjust(Duration.seconds(1));
284 assert.lengthOf(tokenRequests(server.requests), 1);
285 // slow_down widens the 5s interval to 10s.
286 yield* TestClock.adjust(Duration.seconds(9));
287 assert.lengthOf(tokenRequests(server.requests), 1);
288 yield* TestClock.adjust(Duration.seconds(1));
289 assert.lengthOf(tokenRequests(server.requests), 2);
290 yield* Fiber.interrupt(fiber);
291 }),
292 );
293
294 it.effect("backs off after a transient upstream failure and keeps polling", () =>
295 Effect.gen(function* () {
296 const server: DeviceFlowServer = {
297 requests: [],
298 tokenReplies: [{ status: 503, body: "upstream unavailable" }, tokenGranted],
299 };
300
301 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
302 Effect.provide(layerDeviceFlow(server)),
303 provideTestEnv,
304 Effect.forkChild,
305 );
306
307 yield* TestClock.adjust(Duration.seconds(5));
308 assert.lengthOf(tokenRequests(server.requests), 1);
309 // The 5xx widens the 5s interval to 10s before the retry.
310 yield* TestClock.adjust(Duration.seconds(9));
311 assert.lengthOf(tokenRequests(server.requests), 1);
312 yield* TestClock.adjust(Duration.seconds(1));
313 const { token } = yield* Fiber.join(fiber);
314 assert.lengthOf(tokenRequests(server.requests), 2);
315 assert.equal(token.accessToken, "access-token-1");
316 }),
317 );
318
319 it.effect("fails with a denied error when the user rejects the request", () =>
320 Effect.gen(function* () {
321 const server: DeviceFlowServer = {
322 requests: [],
323 tokenReplies: [oauthError("access_denied")],
324 };
325
326 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
327 Effect.provide(layerDeviceFlow(server)),
328 provideTestEnv,
329 Effect.flip,
330 Effect.forkChild,
331 );
332 yield* TestClock.adjust(Duration.seconds(5));
333 const result = yield* Fiber.join(fiber);
334
335 assert.instanceOf(result, CliTokenManager.CloudCliAuthorizationDeniedError);
336 assert.lengthOf(tokenRequests(server.requests), 1);
337 }),
338 );
339
340 it.effect("times out once the device code lifetime elapses", () =>
341 Effect.gen(function* () {
342 const server: DeviceFlowServer = {
343 requests: [],
344 tokenReplies: [oauthError("authorization_pending")],
345 };
346
347 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
348 Effect.provide(layerDeviceFlow(server)),
349 provideTestEnv,
350 Effect.flip,
351 Effect.forkChild,
352 );
353 yield* TestClock.adjust(Duration.seconds(600));
354 const result = yield* Fiber.join(fiber);
355
356 assert.instanceOf(result, CliTokenManager.CloudCliAuthorizationTimeoutError);
357 }),
358 );
359
360 it.effect("surfaces other OAuth errors as authorization failures", () =>
361 Effect.gen(function* () {
362 const server: DeviceFlowServer = {
363 requests: [],
364 tokenReplies: [oauthError("invalid_client")],
365 };
366
367 const fiber = yield* CliTokenManager.deviceAuthorizationLogin(() => Effect.void).pipe(
368 Effect.provide(layerDeviceFlow(server)),
369 provideTestEnv,
370 Effect.flip,
371 Effect.forkChild,
372 );
373 yield* TestClock.adjust(Duration.seconds(5));
374 const result = yield* Fiber.join(fiber);
375
376 assert.isTrue(isAuthorizationError(result));
377 }),
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 );
393});