offrouter-core 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/auth/credential-chain.test.ts +124 -0
- package/src/auth/credential-chain.ts +55 -4
- package/src/auth/index.ts +14 -1
- package/src/auth/keychain.test.ts +154 -1
- package/src/auth/keychain.ts +111 -0
- package/src/auth/oauth-pkce.test.ts +147 -2
- package/src/auth/oauth-pkce.ts +175 -13
- package/src/auth/oauth-server.test.ts +83 -0
- package/src/auth/oauth-server.ts +296 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +4 -0
- package/src/index.ts +17 -0
- package/src/usage-file.test.ts +243 -0
- package/src/usage-file.ts +435 -0
- package/src/usage.ts +44 -17
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for OAuth PKCE flow helpers.
|
|
3
3
|
*/
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
import {
|
|
6
6
|
generateCodeVerifier,
|
|
7
7
|
generateCodeChallenge,
|
|
8
8
|
PKCE_CONFIGS,
|
|
9
9
|
createOAuthState,
|
|
10
10
|
isOAuthProvider,
|
|
11
|
+
defaultCallbackPort,
|
|
12
|
+
resolveClientId,
|
|
13
|
+
runOAuthLogin,
|
|
11
14
|
} from "./oauth-pkce.js";
|
|
15
|
+
import { FileSecretStore } from "../secrets.js";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
19
|
+
import { getOAuthTokens } from "./keychain.js";
|
|
12
20
|
|
|
13
21
|
describe("PKCE code verifier and challenge", () => {
|
|
14
22
|
it("generates a code verifier of correct length", () => {
|
|
@@ -181,4 +189,141 @@ describe("refreshTokens", () => {
|
|
|
181
189
|
refreshTokens("anthropic", "old-refresh-token", undefined as any),
|
|
182
190
|
).rejects.toThrow("SecretStore is required");
|
|
183
191
|
});
|
|
184
|
-
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("defaultCallbackPort", () => {
|
|
195
|
+
it("returns 1456 for anthropic", () => {
|
|
196
|
+
expect(defaultCallbackPort("anthropic")).toBe(1456);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("returns 1455 for openai", () => {
|
|
200
|
+
expect(defaultCallbackPort("openai")).toBe(1455);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("resolveClientId", () => {
|
|
205
|
+
it("reads from the right env var for anthropic", () => {
|
|
206
|
+
const id = resolveClientId("anthropic", {
|
|
207
|
+
OFFROUTER_ANTHROPIC_CLIENT_ID: "ant-id-from-env",
|
|
208
|
+
} as NodeJS.ProcessEnv);
|
|
209
|
+
expect(id).toBe("ant-id-from-env");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("reads from the right env var for openai", () => {
|
|
213
|
+
const id = resolveClientId("openai", {
|
|
214
|
+
OFFROUTER_OPENAI_CLIENT_ID: "oa-id-from-env",
|
|
215
|
+
} as NodeJS.ProcessEnv);
|
|
216
|
+
expect(id).toBe("oa-id-from-env");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("returns undefined when env var is not set", () => {
|
|
220
|
+
expect(resolveClientId("anthropic", {} as NodeJS.ProcessEnv)).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("runOAuthLogin", () => {
|
|
225
|
+
it("stores structured OAuth tokens in the keychain after the full flow", async () => {
|
|
226
|
+
const dir = mkdtempSync(join(tmpdir(), "offrouter-run-oauth-"));
|
|
227
|
+
try {
|
|
228
|
+
const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
|
|
229
|
+
const openBrowser = vi.fn();
|
|
230
|
+
let capturedAuthorizeUrl = "";
|
|
231
|
+
|
|
232
|
+
// Mock fetch: return a fake token response on the token exchange.
|
|
233
|
+
const mockFetch = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
234
|
+
const body = typeof init?.body === "string" ? init.body : "";
|
|
235
|
+
if (body.includes("grant_type=authorization_code")) {
|
|
236
|
+
return new Response(
|
|
237
|
+
JSON.stringify({
|
|
238
|
+
access_token: "ant-access-token-mock",
|
|
239
|
+
refresh_token: "ant-refresh-token-mock",
|
|
240
|
+
expires_in: 3600,
|
|
241
|
+
scope: "api offline_access",
|
|
242
|
+
}),
|
|
243
|
+
{
|
|
244
|
+
status: 200,
|
|
245
|
+
headers: { "content-type": "application/json" },
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return new Response("ok", { status: 200 });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Start login; it will block on waitForCode.
|
|
253
|
+
const loginPromise = runOAuthLogin({
|
|
254
|
+
provider: "anthropic",
|
|
255
|
+
store,
|
|
256
|
+
clientId: "test-client-id",
|
|
257
|
+
port: 0,
|
|
258
|
+
timeoutMs: 5000,
|
|
259
|
+
openBrowser,
|
|
260
|
+
onAuthorizeUrl: (url) => { capturedAuthorizeUrl = url; },
|
|
261
|
+
fetch: mockFetch as unknown as typeof globalThis.fetch,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Wait until the authorize URL is emitted (server is ready).
|
|
265
|
+
await vi.waitFor(() => {
|
|
266
|
+
expect(capturedAuthorizeUrl).toBeTruthy();
|
|
267
|
+
}, { timeout: 2000, interval: 50 });
|
|
268
|
+
|
|
269
|
+
// Parse url to get redirect_uri and state.
|
|
270
|
+
const parsed = new URL(capturedAuthorizeUrl);
|
|
271
|
+
const state = parsed.searchParams.get("state");
|
|
272
|
+
const redirectUri = parsed.searchParams.get("redirect_uri");
|
|
273
|
+
expect(state).toBeTruthy();
|
|
274
|
+
expect(redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/);
|
|
275
|
+
|
|
276
|
+
// Emulate the provider callback.
|
|
277
|
+
const cbRes = await fetch(
|
|
278
|
+
`${redirectUri}?code=TESTCODE&state=${encodeURIComponent(state!)}`,
|
|
279
|
+
{ redirect: "manual" },
|
|
280
|
+
);
|
|
281
|
+
expect(cbRes.status).toBe(200);
|
|
282
|
+
const body = await cbRes.text();
|
|
283
|
+
expect(body).toMatch(/success|complete|authorized/i);
|
|
284
|
+
|
|
285
|
+
// Await the login result.
|
|
286
|
+
const result = await loginPromise;
|
|
287
|
+
expect(result.provider).toBe("anthropic");
|
|
288
|
+
expect(result.redirectUri).toBe(redirectUri);
|
|
289
|
+
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
|
290
|
+
|
|
291
|
+
// Verify tokens stored in keychain.
|
|
292
|
+
const stored = await getOAuthTokens("anthropic", store);
|
|
293
|
+
expect(stored).toBeDefined();
|
|
294
|
+
expect(stored!.accessToken).toBe("ant-access-token-mock");
|
|
295
|
+
expect(stored!.refreshToken).toBe("ant-refresh-token-mock");
|
|
296
|
+
expect(stored!.expiresAt).toBeGreaterThan(Date.now());
|
|
297
|
+
expect(stored!.scope).toBe("api offline_access");
|
|
298
|
+
|
|
299
|
+
// Verify openBrowser was called.
|
|
300
|
+
expect(openBrowser).toHaveBeenCalledWith(capturedAuthorizeUrl);
|
|
301
|
+
} finally {
|
|
302
|
+
rmSync(dir, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("throws for unsupported provider", async () => {
|
|
307
|
+
const dir = mkdtempSync(join(tmpdir(), "offrouter-oauth-bad-"));
|
|
308
|
+
try {
|
|
309
|
+
const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
|
|
310
|
+
await expect(
|
|
311
|
+
runOAuthLogin({ provider: "gemini", store }),
|
|
312
|
+
).rejects.toThrow(/not supported/i);
|
|
313
|
+
} finally {
|
|
314
|
+
rmSync(dir, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("throws if client id cannot be resolved", async () => {
|
|
319
|
+
const dir = mkdtempSync(join(tmpdir(), "offrouter-oauth-noid-"));
|
|
320
|
+
try {
|
|
321
|
+
const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
|
|
322
|
+
await expect(
|
|
323
|
+
runOAuthLogin({ provider: "anthropic", store, port: 0 }),
|
|
324
|
+
).rejects.toThrow(/client id required|client.*id/i);
|
|
325
|
+
} finally {
|
|
326
|
+
rmSync(dir, { recursive: true, force: true });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
package/src/auth/oauth-pkce.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import type { SecretStore } from "../secrets.js";
|
|
12
12
|
import { Redacted } from "./credential-chain.js";
|
|
13
|
+
import { setOAuthTokens } from "./keychain.js";
|
|
14
|
+
import { startCallbackServer } from "./oauth-server.js";
|
|
13
15
|
|
|
14
16
|
// ---------------------------------------------------------------------------
|
|
15
17
|
// PKCE code generation
|
|
@@ -312,15 +314,22 @@ export async function exchangeCodeForTokens(
|
|
|
312
314
|
);
|
|
313
315
|
}
|
|
314
316
|
|
|
315
|
-
// Store tokens in secret store
|
|
317
|
+
// Store tokens in secret store (structured)
|
|
316
318
|
if (options.store) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
json.
|
|
322
|
-
|
|
323
|
-
|
|
319
|
+
const obtainedAt = Date.now();
|
|
320
|
+
await setOAuthTokens(
|
|
321
|
+
provider,
|
|
322
|
+
{
|
|
323
|
+
accessToken: json.access_token,
|
|
324
|
+
refreshToken: json.refresh_token,
|
|
325
|
+
expiresAt: json.expires_in
|
|
326
|
+
? obtainedAt + json.expires_in * 1000
|
|
327
|
+
: undefined,
|
|
328
|
+
scope: json.scope,
|
|
329
|
+
obtainedAt,
|
|
330
|
+
},
|
|
331
|
+
options.store,
|
|
332
|
+
);
|
|
324
333
|
}
|
|
325
334
|
|
|
326
335
|
return {
|
|
@@ -333,6 +342,149 @@ export async function exchangeCodeForTokens(
|
|
|
333
342
|
};
|
|
334
343
|
}
|
|
335
344
|
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Login orchestration (callback server + browser + token exchange)
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
/** Default loopback ports for supported providers. */
|
|
350
|
+
const DEFAULT_CALLBACK_PORTS: Record<string, number> = {
|
|
351
|
+
anthropic: 1456,
|
|
352
|
+
openai: 1455,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Return the default callback port for a provider, or 0 (OS-assigned).
|
|
357
|
+
*/
|
|
358
|
+
export function defaultCallbackPort(provider: string): number {
|
|
359
|
+
return DEFAULT_CALLBACK_PORTS[provider] ?? 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Resolve the client id for a provider from process env (or a provided env
|
|
364
|
+
* override). Returns undefined if the env var is not set.
|
|
365
|
+
*/
|
|
366
|
+
export function resolveClientId(
|
|
367
|
+
provider: string,
|
|
368
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
369
|
+
): string | undefined {
|
|
370
|
+
const config = PKCE_CONFIGS[provider];
|
|
371
|
+
if (!config?.clientIdEnvVar) return undefined;
|
|
372
|
+
return env[config.clientIdEnvVar] ?? undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface RunOAuthLoginOptions {
|
|
376
|
+
provider: string;
|
|
377
|
+
store: SecretStore;
|
|
378
|
+
/** Override client id (else resolved from env / PKCE_CONFIGS). */
|
|
379
|
+
clientId?: string;
|
|
380
|
+
/** Loopback port (defaults to provider default or 0). */
|
|
381
|
+
port?: number;
|
|
382
|
+
/** Wait timeout in ms (default 5 min). */
|
|
383
|
+
timeoutMs?: number;
|
|
384
|
+
/** Called with the authorize URL once built (test/display). */
|
|
385
|
+
onAuthorizeUrl?: (url: string) => void;
|
|
386
|
+
/** Best-effort browser open (non-throwing). */
|
|
387
|
+
openBrowser?: (url: string) => Promise<boolean> | boolean;
|
|
388
|
+
/** Injectable fetch (tests). */
|
|
389
|
+
fetch?: typeof globalThis.fetch;
|
|
390
|
+
/** Optional abort signal. */
|
|
391
|
+
signal?: AbortSignal;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export interface RunOAuthLoginResult {
|
|
395
|
+
provider: string;
|
|
396
|
+
redirectUri: string;
|
|
397
|
+
expiresAt?: number;
|
|
398
|
+
scope?: string;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Run the full OAuth PKCE login flow:
|
|
403
|
+
* 1. Start a local callback server on 127.0.0.1
|
|
404
|
+
* 2. Build the authorize URL with PKCE challenge + state
|
|
405
|
+
* 3. Notify caller (onAuthorizeUrl) and attempt browser open
|
|
406
|
+
* 4. Wait for the provider callback (code + state)
|
|
407
|
+
* 5. Exchange code for tokens
|
|
408
|
+
* 6. Store structured tokens in the secret store
|
|
409
|
+
* 7. Clean up the server
|
|
410
|
+
*/
|
|
411
|
+
export async function runOAuthLogin(
|
|
412
|
+
opts: RunOAuthLoginOptions,
|
|
413
|
+
): Promise<RunOAuthLoginResult> {
|
|
414
|
+
const config = PKCE_CONFIGS[opts.provider];
|
|
415
|
+
if (!config) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`OAuth not supported for provider: ${opts.provider}. ` +
|
|
418
|
+
`OAuth is only available for anthropic and openai providers.`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const clientId =
|
|
423
|
+
opts.clientId ?? resolveClientId(opts.provider) ?? undefined;
|
|
424
|
+
if (!clientId) {
|
|
425
|
+
const envHint =
|
|
426
|
+
config.clientIdEnvVar
|
|
427
|
+
? `Set the ${config.clientIdEnvVar} environment variable or pass it explicitly.`
|
|
428
|
+
: "A client id is required for this provider.";
|
|
429
|
+
throw new Error(
|
|
430
|
+
`Client ID required for ${opts.provider} OAuth. ${envHint}`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 1. Start callback server
|
|
435
|
+
const port = opts.port ?? defaultCallbackPort(opts.provider);
|
|
436
|
+
const server = await startCallbackServer(port);
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
const redirectUri = server.url;
|
|
440
|
+
|
|
441
|
+
// 2. Build the authorize URL with PKCE
|
|
442
|
+
const flow = await startOAuthFlow(opts.provider, {
|
|
443
|
+
redirectUri,
|
|
444
|
+
clientId,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// 3. Notify and attempt to open browser
|
|
448
|
+
opts.onAuthorizeUrl?.(flow.authorizeUrl);
|
|
449
|
+
if (opts.openBrowser) {
|
|
450
|
+
try {
|
|
451
|
+
await opts.openBrowser(flow.authorizeUrl);
|
|
452
|
+
} catch {
|
|
453
|
+
// Best-effort only.
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 4. Wait for callback (validates expected state)
|
|
458
|
+
const { code } = await server.waitForCode({
|
|
459
|
+
expectedState: flow.state,
|
|
460
|
+
timeoutMs: opts.timeoutMs,
|
|
461
|
+
signal: opts.signal,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// 5. Exchange code for tokens (also stores them)
|
|
465
|
+
const tokens = await exchangeCodeForTokens(opts.provider, code, flow.state, {
|
|
466
|
+
store: opts.store,
|
|
467
|
+
fetch: opts.fetch,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// 6. Tokens are already stored by exchangeCodeForTokens.
|
|
471
|
+
// Compute expiresAt from expiresIn for the result.
|
|
472
|
+
const expiresAt = tokens.expiresIn
|
|
473
|
+
? Date.now() + tokens.expiresIn * 1000
|
|
474
|
+
: undefined;
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
provider: opts.provider,
|
|
478
|
+
redirectUri,
|
|
479
|
+
expiresAt,
|
|
480
|
+
scope: tokens.scope,
|
|
481
|
+
};
|
|
482
|
+
} finally {
|
|
483
|
+
// 7. Clean up
|
|
484
|
+
await server.close();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
336
488
|
/**
|
|
337
489
|
* Refresh an OAuth token using a refresh token.
|
|
338
490
|
* Updates the stored tokens on success.
|
|
@@ -392,11 +544,21 @@ export async function refreshTokens(
|
|
|
392
544
|
);
|
|
393
545
|
}
|
|
394
546
|
|
|
395
|
-
// Update stored tokens
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
547
|
+
// Update stored tokens (structured)
|
|
548
|
+
const obtainedAt = Date.now();
|
|
549
|
+
await setOAuthTokens(
|
|
550
|
+
provider,
|
|
551
|
+
{
|
|
552
|
+
accessToken: json.access_token,
|
|
553
|
+
refreshToken: json.refresh_token,
|
|
554
|
+
expiresAt: json.expires_in
|
|
555
|
+
? obtainedAt + json.expires_in * 1000
|
|
556
|
+
: undefined,
|
|
557
|
+
scope: json.scope,
|
|
558
|
+
obtainedAt,
|
|
559
|
+
},
|
|
560
|
+
store,
|
|
561
|
+
);
|
|
400
562
|
|
|
401
563
|
return {
|
|
402
564
|
accessToken: new Redacted(json.access_token),
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the local OAuth callback server.
|
|
3
|
+
* No real OAuth: we hit the callback URL directly over HTTP.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { startCallbackServer } from "./oauth-server.js";
|
|
7
|
+
|
|
8
|
+
describe("startCallbackServer", () => {
|
|
9
|
+
it("binds to 127.0.0.1 and exposes a localhost redirect URL", async () => {
|
|
10
|
+
const server = await startCallbackServer(0);
|
|
11
|
+
try {
|
|
12
|
+
expect(server.url).toContain("127.0.0.1");
|
|
13
|
+
expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/);
|
|
14
|
+
expect(server.port).toBeGreaterThan(0);
|
|
15
|
+
} finally {
|
|
16
|
+
await server.close();
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("captures the authorization code from a matching callback", async () => {
|
|
21
|
+
const server = await startCallbackServer(0);
|
|
22
|
+
try {
|
|
23
|
+
const waitPromise = server.waitForCode({
|
|
24
|
+
expectedState: "state-abc",
|
|
25
|
+
timeoutMs: 2000,
|
|
26
|
+
});
|
|
27
|
+
const res = await fetch(`${server.url}?code=AUTHCODE123&state=state-abc`);
|
|
28
|
+
expect(res.status).toBe(200);
|
|
29
|
+
const body = await res.text();
|
|
30
|
+
expect(body).toMatch(/success|complete|authorized/i);
|
|
31
|
+
|
|
32
|
+
const result = await waitPromise;
|
|
33
|
+
expect(result.code).toBe("AUTHCODE123");
|
|
34
|
+
expect(result.state).toBe("state-abc");
|
|
35
|
+
} finally {
|
|
36
|
+
await server.close();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects a callback with a mismatched state", async () => {
|
|
41
|
+
const server = await startCallbackServer(0);
|
|
42
|
+
try {
|
|
43
|
+
const waitPromise = server.waitForCode({
|
|
44
|
+
expectedState: "expected-state",
|
|
45
|
+
timeoutMs: 2000,
|
|
46
|
+
});
|
|
47
|
+
// Attach the rejection handler before triggering the callback so the
|
|
48
|
+
// rejection is never momentarily unhandled.
|
|
49
|
+
const assertion = expect(waitPromise).rejects.toThrow(/state/i);
|
|
50
|
+
await fetch(`${server.url}?code=CODE&state=wrong-state`);
|
|
51
|
+
await assertion;
|
|
52
|
+
} finally {
|
|
53
|
+
await server.close();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects when no callback arrives before the timeout", async () => {
|
|
58
|
+
const server = await startCallbackServer(0);
|
|
59
|
+
try {
|
|
60
|
+
await expect(
|
|
61
|
+
server.waitForCode({ timeoutMs: 50 }),
|
|
62
|
+
).rejects.toThrow(/timed out|timeout/i);
|
|
63
|
+
} finally {
|
|
64
|
+
await server.close();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns an error page for callbacks missing the code", async () => {
|
|
69
|
+
const server = await startCallbackServer(0);
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(`${server.url}?state=only-state`);
|
|
72
|
+
expect(res.status).toBe(400);
|
|
73
|
+
} finally {
|
|
74
|
+
await server.close();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("close is idempotent and releases the port", async () => {
|
|
79
|
+
const server = await startCallbackServer(0);
|
|
80
|
+
await server.close();
|
|
81
|
+
await expect(server.close()).resolves.toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|