Skip to content
Source
Sign inSign up
Open History to see the last commit

auth.test.ts

329 lines13.2 KB

1import type { SdkLoginOptions, StoredSdkCredentials } from "@cursor/sdk";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Layer from "effect/Layer";
5import { expect, it } from "@effect/vitest";
6import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import * as Clock from "effect/Clock";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";
13import * as Logger from "effect/Logger";
14import * as Option from "effect/Option";
15import * as Stream from "effect/Stream";
16​
17import { makeCursorAuth, type CursorAuthOptions } from "./auth.ts";
18import { loadCursorSdk } from "./sdk.ts";
19​
20const { AuthenticationError, InMemoryCredentialStore } = loadCursorSdk();
21​
22const instanceId = ProviderInstanceId.make("cursor-personal");
23const owner = "owner-client";
24const otherOwner = "other-client";
25const authorizationUrl = "https://cursor.com/loginDeepControl?challenge=test-challenge";
26​
27const makeHarness = Effect.fn("makeCursorAuthHarness")(function* (
28 overrides: Partial<CursorAuthOptions> = {},
29) {
30 const store = new InMemoryCredentialStore();
31 const complete = Promise.withResolvers<void>();
32 const returned = Promise.withResolvers<void>();
33 const now = yield* Clock.currentTimeMillis;
34 const credentials: StoredSdkCredentials = {
35 version: 1,
36 backendUrl: "https://api2.cursor.sh",
37 apiKey: "browser-key",
38 createdAtMs: now,
39 apiKeyExpiresAtMs: now + 86_400_000,
40 email: "cursor@example.com",
41 };
42 let loginOptions: SdkLoginOptions | undefined;
43 const changes: boolean[] = [];
44 const auth = yield* makeCursorAuth({
45 instanceId,
46 displayName: "Personal Cursor",
47 enabled: true,
48 store,
49 onChanged: (signedIn) =>
50 Effect.sync(() => {
51 changes.push(signedIn);
52 }),
53 login: async (options) => {
54 loginOptions = options;
55 options.onLoginUrl?.(authorizationUrl);
56 await complete.promise;
57 await options.store?.save(credentials);
58 returned.resolve();
59 return {
60 apiKey: credentials.apiKey,
61 apiKeyExpiresAtMs: credentials.apiKeyExpiresAtMs!,
62 email: credentials.email!,
63 };
64 },
65 ...overrides,
66 });
67 return {
68 auth,
69 store,
70 complete,
71 returned,
72 credentials,
73 changes,
74 loginOptions: () => loginOptions,
75 };
76});
77​
78const captureWarnings = (warnings: string[]) =>
79 Logger.layer([
80 Logger.make(({ logLevel, cause }) => {
81 if (logLevel === "Warn") warnings.push(Cause.pretty(cause));
82 }),
83 ]);
84​
85type Auth = Effect.Success<ReturnType<typeof makeCursorAuth>>;
86const phase = (auth: Auth, phase: ProviderAuthState["phase"], session = owner) =>
87 auth.controller.subscribe(session).pipe(
88 Stream.filter((state) => state.phase === phase),
89 Stream.runHead,
90 Effect.map(Option.getOrThrow),
91 );
92​
93it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("CursorAuth", (it) => {
94 it.effect("closes SDK sessions and interrupts in-flight credential work before signing out", () =>
95 Effect.gen(function* () {
96 const harness = yield* makeHarness();
97 yield* Effect.promise(() => harness.store.save(harness.credentials));
98 const acquired = yield* Deferred.make<void>();
99 let closedSession = false;
100 let closedRequest = false;
101 yield* harness.auth.withAccess(
102 Effect.acquireRelease(Effect.succeed("session"), () =>
103 Effect.sync(() => {
104 closedSession = true;
105 }),
106 ),
107 );
108 const request = yield* harness.auth
109 .withAccess(
110 Effect.gen(function* () {
111 yield* Effect.addFinalizer(() =>
112 Effect.sync(() => {
113 closedRequest = true;
114 }),
115 );
116 yield* Deferred.succeed(acquired, undefined);
117 return yield* Effect.never;
118 }),
119 )
120 .pipe(Effect.forkScoped);
121 yield* Deferred.await(acquired);
122 yield* harness.auth.controller.logout(Effect.void);
123 expect(closedSession).toBe(true);
124 expect(closedRequest).toBe(true);
125 expect(Exit.isFailure(yield* Fiber.await(request))).toBe(true);
126 expect(yield* harness.auth.readApiKey).toBeUndefined();
127 }),
128 );
129​
130 it.effect("completes a remote browser login, saves its key and refreshes provider status", () =>
131 Effect.gen(function* () {
132 const harness = yield* makeHarness();
133 let stopped = false;
134 expect(yield* harness.auth.readApiKey).toBeUndefined();
135 const starting = yield* harness.auth.controller.start(
136 owner,
137 Effect.sync(() => {
138 stopped = true;
139 }),
140 );
141 const waiting = yield* phase(harness.auth, "waiting");
142 expect(stopped).toBe(true);
143 expect(waiting.authorizationUrl).toBe(authorizationUrl);
144 expect(waiting.flowId).toBe(starting.flowId);
145 expect(harness.loginOptions()?.openBrowser).toBe(false);
146 expect((yield* harness.auth.controller.start(owner)).flowId).toBe(starting.flowId);
147 expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
148 yield* Effect.sync(() => harness.complete.resolve());
149 const succeeded = yield* phase(harness.auth, "succeeded");
150 expect(succeeded.authorizationUrl).toBeNull();
151 expect(yield* harness.auth.requireApiKey).toBe("browser-key");
152 expect(harness.changes).toEqual([true]);
153 expect(Object.values(succeeded)).not.toContain("browser-key");
154 expect(yield* Effect.promise(() => harness.store.load())).toEqual(harness.credentials);
155 }),
156 );
157​
158 it.effect("keeps the login link private to its owner and rejects other clients' mutations", () =>
159 Effect.gen(function* () {
160 const { auth } = yield* makeHarness();
161 yield* auth.controller.start(owner);
162 const waiting = yield* phase(auth, "waiting");
163 const foreign = yield* phase(auth, "waiting", otherOwner);
164 expect(foreign).toMatchObject({ flowId: null, authorizationUrl: null, expiresAt: null });
165 expect(Exit.isFailure(yield* Effect.exit(auth.controller.start(otherOwner)))).toBe(true);
166 expect(
167 Exit.isFailure(yield* Effect.exit(auth.controller.cancel(otherOwner, waiting.flowId!))),
168 ).toBe(true);
169 expect(
170 Exit.isFailure(
171 yield* Effect.exit(
172 auth.controller.complete(owner, {
173 flowId: waiting.flowId!,
174 callbackUrl: "http://localhost/callback",
175 }),
176 ),
177 ),
178 ).toBe(true);
179 expect((yield* phase(auth, "waiting")).authorizationUrl).toBe(authorizationUrl);
180 }),
181 );
182​
183 it.effect("aborts cancellation and ignores a late successful SDK login", () => {
184 const warnings: string[] = [];
185 return Effect.gen(function* () {
186 const harness = yield* makeHarness();
187 yield* harness.auth.controller.start(owner);
188 const waiting = yield* phase(harness.auth, "waiting");
189 const cancelled = yield* harness.auth.controller.cancel(owner, waiting.flowId!);
190 expect(cancelled.phase).toBe("cancelled");
191 expect(cancelled.authorizationUrl).toBeNull();
192 expect(harness.loginOptions()?.signal?.aborted).toBe(true);
193 yield* Effect.sync(() => harness.complete.resolve());
194 yield* Effect.promise(() => harness.returned.promise);
195 expect(yield* harness.auth.readApiKey).toBeUndefined();
196 expect(harness.changes).toEqual([]);
197 expect(warnings).toEqual([]);
198 }).pipe(Effect.scoped, Effect.provide(captureWarnings(warnings)));
199 });
200​
201 it.effect("discards a credential rejected by verification and permits a fresh login", () =>
202 Effect.gen(function* () {
203 const rejectionMessage =
204 "Cursor needs a paid plan to run in T3 Code. Upgrade this account or sign in with a different one.";
205 let rejected = true;
206 const harness = yield* makeHarness({
207 onChanged: (signedIn) =>
208 signedIn && rejected
209 ? Effect.fail(
210 new ProviderSetupError({
211 instanceId,
212 operation: "start",
213 detail: rejectionMessage,
214 }),
215 )
216 : Effect.void,
217 });
218 yield* harness.auth.controller.start(owner);
219 yield* phase(harness.auth, "waiting");
220 yield* Effect.sync(() => harness.complete.resolve());
221 const failed = yield* phase(harness.auth, "failed");
222 expect(failed.message).toBe(rejectionMessage);
223 expect(yield* Effect.promise(() => harness.store.load())).toBeUndefined();
224 expect(yield* harness.auth.readApiKey).toBeUndefined();
225 expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
226 rejected = false;
227 yield* harness.auth.controller.start(owner);
228 yield* phase(harness.auth, "succeeded");
229 expect(yield* harness.auth.requireApiKey).toBe("browser-key");
230 }),
231 );
232​
233 it.effect("shows a fixed reason when browser login fails and logs the SDK cause", () => {
234 const warnings: string[] = [];
235 return Effect.gen(function* () {
236 const sdkMessage =
237 "Login succeeded, but creating an SDK API key failed: [permission_denied] user API keys are disabled for this team (your team's settings may restrict user API keys — ask a team admin, or pass an existing API key directly)";
238 const harness = yield* makeHarness({
239 login: () => Promise.reject(new AuthenticationError(sdkMessage)),
240 });
241 yield* harness.auth.controller.start(owner);
242 const failed = yield* phase(harness.auth, "failed");
243 expect(failed.message).toContain("could not create an API key");
244 expect(failed.message).not.toContain("permission_denied");
245 expect(yield* harness.auth.readApiKey).toBeUndefined();
246 expect(warnings).toHaveLength(1);
247 expect(warnings[0]).toContain(sdkMessage);
248 }).pipe(Effect.scoped, Effect.provide(captureWarnings(warnings)));
249 });
250​
251 it.effect("clears only this instance's credential after its sessions stop", () =>
252 Effect.gen(function* () {
253 const harness = yield* makeHarness();
254 const other = yield* makeHarness({ instanceId: ProviderInstanceId.make("cursor-work") });
255 yield* Effect.promise(() => harness.store.save(harness.credentials));
256 yield* Effect.promise(() => other.store.save({ ...other.credentials, apiKey: "other-key" }));
257 const state = yield* harness.auth.controller.logout(
258 Effect.gen(function* () {
259 expect(yield* harness.auth.readApiKey).toBe("browser-key");
260 expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true);
261 }),
262 );
263 expect(state.message).toBe("Signed out of Cursor.");
264 expect(yield* harness.auth.readApiKey).toBeUndefined();
265 expect(yield* other.auth.requireApiKey).toBe("other-key");
266 expect(harness.changes).toEqual([false]);
267 }),
268 );
269​
270 it.effect("cancels a pending sign-in on logout without saving its eventual key", () =>
271 Effect.gen(function* () {
272 const harness = yield* makeHarness();
273 yield* harness.auth.controller.start(owner);
274 yield* phase(harness.auth, "waiting");
275 yield* harness.auth.controller.logout(Effect.void);
276 expect(harness.loginOptions()?.signal?.aborted).toBe(true);
277 yield* Effect.sync(() => harness.complete.resolve());
278 yield* Effect.promise(() => harness.returned.promise);
279 expect(yield* harness.auth.readApiKey).toBeUndefined();
280 }),
281 );
282​
283 it.effect("retains credentials if sessions cannot be stopped for logout", () =>
284 Effect.gen(function* () {
285 const harness = yield* makeHarness();
286 yield* Effect.promise(() => harness.store.save(harness.credentials));
287 const result = yield* Effect.exit(
288 harness.auth.controller.logout(
289 Effect.fail(
290 new ProviderSetupError({
291 instanceId,
292 operation: "stop",
293 detail: "Unable to stop sessions",
294 }),
295 ),
296 ),
297 );
298 expect(Exit.isFailure(result)).toBe(true);
299 expect(yield* harness.auth.requireApiKey).toBe("browser-key");
300 }),
301 );
302​
303 it.effect(
304 "prefers explicit API keys and prevents browser login from silently overriding them",
305 () =>
306 Effect.gen(function* () {
307 const harness = yield* makeHarness({ apiKey: " explicit-key " });
308 yield* Effect.promise(() => harness.store.save(harness.credentials));
309 expect(yield* harness.auth.requireApiKey).toBe("explicit-key");
310 expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true);
311 expect(
312 Exit.isFailure(yield* Effect.exit(harness.auth.controller.logout(Effect.void))),
313 ).toBe(true);
314 expect(harness.loginOptions()).toBeUndefined();
315 expect((yield* Effect.promise(() => harness.store.load()))?.apiKey).toBe("browser-key");
316 }),
317 );
318​
319 it.effect("ignores expired browser credentials and rejects disabled sign-in", () =>
320 Effect.gen(function* () {
321 const harness = yield* makeHarness({ enabled: false });
322 yield* Effect.promise(() =>
323 harness.store.save({ ...harness.credentials, apiKeyExpiresAtMs: 0 }),
324 );
325 expect(yield* harness.auth.readApiKey).toBeUndefined();
326 expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true);
327 }),
328 );
329});