@pi-archimedes/mcp 2.3.0
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/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +39 -0
- package/src/auth-flow.test.ts +583 -0
- package/src/auth-flow.ts +310 -0
- package/src/auth-run.test.ts +309 -0
- package/src/auth-run.ts +146 -0
- package/src/auth-storage.test.ts +338 -0
- package/src/auth-storage.ts +330 -0
- package/src/auto-auth.test.ts +231 -0
- package/src/auto-auth.ts +135 -0
- package/src/callback-server.test.ts +446 -0
- package/src/callback-server.ts +538 -0
- package/src/commands-auth.test.ts +320 -0
- package/src/commands-auth.ts +128 -0
- package/src/commands.test.ts +834 -0
- package/src/commands.ts +424 -0
- package/src/config-write.test.ts +213 -0
- package/src/config-write.ts +207 -0
- package/src/config.test.ts +468 -0
- package/src/config.ts +278 -0
- package/src/direct-tools.test.ts +473 -0
- package/src/direct-tools.ts +250 -0
- package/src/host-configs.test.ts +231 -0
- package/src/host-configs.ts +106 -0
- package/src/index.test.ts +689 -0
- package/src/index.ts +146 -0
- package/src/lifecycle.test.ts +274 -0
- package/src/lifecycle.ts +77 -0
- package/src/metadata-cache.test.ts +383 -0
- package/src/metadata-cache.ts +231 -0
- package/src/npx-resolver.test.ts +142 -0
- package/src/npx-resolver.ts +126 -0
- package/src/oauth-provider.test.ts +404 -0
- package/src/oauth-provider.ts +197 -0
- package/src/oauth-types.ts +54 -0
- package/src/panel-rows.ts +210 -0
- package/src/panel.test.ts +298 -0
- package/src/panel.ts +742 -0
- package/src/proxy-tool.ts +524 -0
- package/src/renderer.test.ts +326 -0
- package/src/renderer.ts +239 -0
- package/src/schema-validator.test.ts +56 -0
- package/src/schema-validator.ts +42 -0
- package/src/server-client.test.ts +1001 -0
- package/src/server-client.ts +576 -0
- package/src/server-manager.ts +139 -0
- package/src/setup-panel.test.ts +162 -0
- package/src/setup-panel.ts +715 -0
- package/src/tool-naming.test.ts +168 -0
- package/src/tool-naming.ts +114 -0
- package/src/types.ts +162 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { AuthEntry } from "./oauth-types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Boundary mocks for the auth-flow module under test:
|
|
7
|
+
*
|
|
8
|
+
* - `./auth-storage.js` — in-memory AuthEntry per server name. Mirrors the
|
|
9
|
+
* real module's contract: `getAuthEntry` returns a clone, `saveAuthEntry`
|
|
10
|
+
* replaces the stored entry and mutates `entry.serverUrl` when the param
|
|
11
|
+
* is passed.
|
|
12
|
+
* - `@modelcontextprotocol/sdk/client/auth.js` — the `auth()` orchestrator
|
|
13
|
+
* as a spy. The default implementation mirrors the SDK's behaviour at the
|
|
14
|
+
* provider boundary instead of hitting the network: code exchange →
|
|
15
|
+
* saveTokens, non-interactive flow (client_credentials) → saveTokens,
|
|
16
|
+
* stored token with a refresh token → refresh via saveTokens, otherwise
|
|
17
|
+
* → start a new authorization flow (redirectToAuthorization).
|
|
18
|
+
* - `./callback-server.js` — no-op state reservation and port binding;
|
|
19
|
+
* `waitForCallback` returns a deferred the test resolves/rejects to drive
|
|
20
|
+
* the callback window, and `getCallbackPort`/`getCallbackPath` are pinned
|
|
21
|
+
* so the real (unmocked) `McpOAuthProvider` builds deterministic URLs.
|
|
22
|
+
*
|
|
23
|
+
* The real `McpOAuthProvider` runs against these mocks, so the tests verify
|
|
24
|
+
* the auth-flow wiring end to end: flow → provider → SDK → callback →
|
|
25
|
+
* exchange → storage.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const storage = vi.hoisted(() => {
|
|
29
|
+
type EntryMap = Map<string, AuthEntry>;
|
|
30
|
+
const entries: EntryMap = new Map();
|
|
31
|
+
const clone = <T>(value: T): T => (value === undefined ? value : structuredClone(value));
|
|
32
|
+
|
|
33
|
+
const getAuthEntry = vi.fn((serverName: string): AuthEntry | undefined =>
|
|
34
|
+
clone(entries.get(serverName)),
|
|
35
|
+
);
|
|
36
|
+
const saveAuthEntry = vi.fn(
|
|
37
|
+
(serverName: string, entry: AuthEntry, serverUrl?: string): void => {
|
|
38
|
+
if (serverUrl !== undefined) entry.serverUrl = serverUrl;
|
|
39
|
+
entries.set(serverName, clone(entry));
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
const deleteAuthEntry = vi.fn((serverName: string): void => {
|
|
43
|
+
entries.delete(serverName);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
entries,
|
|
48
|
+
getAuthEntry,
|
|
49
|
+
saveAuthEntry,
|
|
50
|
+
deleteAuthEntry,
|
|
51
|
+
seed(serverName: string, entry: AuthEntry): void {
|
|
52
|
+
entries.set(serverName, clone(entry));
|
|
53
|
+
},
|
|
54
|
+
reset(): void {
|
|
55
|
+
entries.clear();
|
|
56
|
+
getAuthEntry.mockClear();
|
|
57
|
+
saveAuthEntry.mockClear();
|
|
58
|
+
deleteAuthEntry.mockClear();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const sdk = vi.hoisted(() => ({
|
|
64
|
+
/** The SDK's auth() orchestrator, spied. See default impl below. */
|
|
65
|
+
auth: vi.fn(),
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Default SDK `auth()` mock implementation: mirrors the provider-boundary
|
|
70
|
+
* behaviour of the real orchestrator so storage assertions are meaningful:
|
|
71
|
+
* - `authorizationCode` → token exchange via saveTokens → "AUTHORIZED"
|
|
72
|
+
* - non-interactive (redirectUrl undefined, e.g. client_credentials) →
|
|
73
|
+
* direct token fetch via saveTokens → "AUTHORIZED"
|
|
74
|
+
* - stored token with refresh_token (the expired-tokens test case) →
|
|
75
|
+
* refresh via saveTokens → "AUTHORIZED"
|
|
76
|
+
* - otherwise → new authorization flow: redirectToAuthorization → "REDIRECT"
|
|
77
|
+
*/
|
|
78
|
+
const sdkAuthDefaultImpl = vi.hoisted(
|
|
79
|
+
() =>
|
|
80
|
+
async (
|
|
81
|
+
provider: {
|
|
82
|
+
redirectUrl?: string | URL | undefined;
|
|
83
|
+
tokens(): Promise<{ refresh_token?: string } | undefined>;
|
|
84
|
+
saveTokens(tokens: {
|
|
85
|
+
access_token: string;
|
|
86
|
+
token_type: string;
|
|
87
|
+
expires_in?: number;
|
|
88
|
+
refresh_token?: string;
|
|
89
|
+
scope?: string;
|
|
90
|
+
}): Promise<void>;
|
|
91
|
+
redirectToAuthorization(url: URL): Promise<void>;
|
|
92
|
+
},
|
|
93
|
+
options: { authorizationCode?: string },
|
|
94
|
+
): Promise<string> => {
|
|
95
|
+
if (options.authorizationCode !== undefined) {
|
|
96
|
+
await provider.saveTokens({
|
|
97
|
+
access_token: `at-${options.authorizationCode}`,
|
|
98
|
+
token_type: "Bearer",
|
|
99
|
+
expires_in: 3600,
|
|
100
|
+
});
|
|
101
|
+
return "AUTHORIZED";
|
|
102
|
+
}
|
|
103
|
+
if (provider.redirectUrl === undefined) {
|
|
104
|
+
await provider.saveTokens({ access_token: "cc-at", token_type: "Bearer", expires_in: 3600 });
|
|
105
|
+
return "AUTHORIZED";
|
|
106
|
+
}
|
|
107
|
+
const tokens = await provider.tokens();
|
|
108
|
+
if (tokens?.refresh_token) {
|
|
109
|
+
await provider.saveTokens({
|
|
110
|
+
access_token: "refreshed-at",
|
|
111
|
+
token_type: "Bearer",
|
|
112
|
+
expires_in: 3600,
|
|
113
|
+
refresh_token: tokens.refresh_token,
|
|
114
|
+
});
|
|
115
|
+
return "AUTHORIZED";
|
|
116
|
+
}
|
|
117
|
+
await provider.redirectToAuthorization(
|
|
118
|
+
new URL("https://auth.example.com/authorize?state=demo"),
|
|
119
|
+
);
|
|
120
|
+
return "REDIRECT";
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const callback = vi.hoisted(() => {
|
|
125
|
+
type OAuthCallbackResult = { code: string; iss?: string };
|
|
126
|
+
interface Deferred {
|
|
127
|
+
promise: Promise<OAuthCallbackResult>;
|
|
128
|
+
resolve(value: OAuthCallbackResult): void;
|
|
129
|
+
reject(error: Error): void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const reserveAuthState = vi.fn();
|
|
133
|
+
// NOT the default port (19876): a dynamic bind resolves an OS-assigned
|
|
134
|
+
// port, and the provider must advertise THAT port, not getCallbackPort().
|
|
135
|
+
const ensureCallbackServer = vi.fn(async () => 43217);
|
|
136
|
+
const stopCallbackServer = vi.fn(async () => undefined);
|
|
137
|
+
|
|
138
|
+
const waiters: Deferred[] = [];
|
|
139
|
+
const waitForCallbackImpl = (
|
|
140
|
+
_state: string,
|
|
141
|
+
_signal?: AbortSignal,
|
|
142
|
+
): Promise<OAuthCallbackResult> => {
|
|
143
|
+
let resolve: (value: OAuthCallbackResult) => void = () => {};
|
|
144
|
+
let reject: (error: Error) => void = () => {};
|
|
145
|
+
const promise = new Promise<OAuthCallbackResult>((res, rej) => {
|
|
146
|
+
resolve = res;
|
|
147
|
+
reject = rej;
|
|
148
|
+
});
|
|
149
|
+
waiters.push({ promise, resolve, reject });
|
|
150
|
+
return promise;
|
|
151
|
+
};
|
|
152
|
+
const waitForCallback = vi.fn(waitForCallbackImpl);
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
reserveAuthState,
|
|
156
|
+
ensureCallbackServer,
|
|
157
|
+
stopCallbackServer,
|
|
158
|
+
waitForCallback,
|
|
159
|
+
waiters,
|
|
160
|
+
reset(): void {
|
|
161
|
+
waiters.length = 0;
|
|
162
|
+
reserveAuthState.mockClear();
|
|
163
|
+
ensureCallbackServer.mockReset();
|
|
164
|
+
ensureCallbackServer.mockImplementation(async () => 43217);
|
|
165
|
+
stopCallbackServer.mockClear();
|
|
166
|
+
stopCallbackServer.mockImplementation(async () => undefined);
|
|
167
|
+
waitForCallback.mockReset();
|
|
168
|
+
waitForCallback.mockImplementation(waitForCallbackImpl);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
vi.mock("./auth-storage.js", () => ({
|
|
174
|
+
getAuthEntry: storage.getAuthEntry,
|
|
175
|
+
saveAuthEntry: storage.saveAuthEntry,
|
|
176
|
+
deleteAuthEntry: storage.deleteAuthEntry,
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
|
180
|
+
auth: sdk.auth,
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
vi.mock("./callback-server.js", () => ({
|
|
184
|
+
reserveAuthState: callback.reserveAuthState,
|
|
185
|
+
ensureCallbackServer: callback.ensureCallbackServer,
|
|
186
|
+
waitForCallback: callback.waitForCallback,
|
|
187
|
+
stopCallbackServer: callback.stopCallbackServer,
|
|
188
|
+
getCallbackPort: () => 19876,
|
|
189
|
+
getCallbackPath: () => "/callback",
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
// The module under test (fails to resolve until it exists — RED state).
|
|
193
|
+
import { authenticate, extractOAuthConfig, getValidToken } from "./auth-flow.js";
|
|
194
|
+
|
|
195
|
+
const SERVER_NAME = "flown";
|
|
196
|
+
const SERVER_URL = "https://mcp.example.com/mcp";
|
|
197
|
+
|
|
198
|
+
/** Fixed wall clock for deterministic expiresAt/expires_in math. */
|
|
199
|
+
const FAKE_NOW_MS = Date.parse("2026-03-01T12:00:00.000Z");
|
|
200
|
+
const FAKE_NOW_SECONDS = FAKE_NOW_MS / 1000;
|
|
201
|
+
|
|
202
|
+
/** Seeded stored token whose expiry lies `offset` seconds from the fake now. */
|
|
203
|
+
function storedToken(expiryOffsetSeconds: number | null) {
|
|
204
|
+
return {
|
|
205
|
+
accessToken: "expired-at",
|
|
206
|
+
refreshToken: "rt-1",
|
|
207
|
+
...(expiryOffsetSeconds === null ? {} : { expiresAt: FAKE_NOW_SECONDS + expiryOffsetSeconds }),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
storage.reset();
|
|
213
|
+
callback.reset();
|
|
214
|
+
sdk.auth.mockReset();
|
|
215
|
+
sdk.auth.mockImplementation(sdkAuthDefaultImpl);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("extractOAuthConfig", () => {
|
|
219
|
+
it('"oauth" → default authorization_code config', () => {
|
|
220
|
+
expect(extractOAuthConfig("oauth")).toEqual({ grantType: "authorization_code" });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("other strings, nullish, and numbers → null", () => {
|
|
224
|
+
expect(extractOAuthConfig("http")).toBeNull();
|
|
225
|
+
expect(extractOAuthConfig("oauth2")).toBeNull();
|
|
226
|
+
expect(extractOAuthConfig(undefined)).toBeNull();
|
|
227
|
+
expect(extractOAuthConfig(null)).toBeNull();
|
|
228
|
+
expect(extractOAuthConfig(42)).toBeNull();
|
|
229
|
+
expect(extractOAuthConfig([])).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("keeps only oauth fields from an oauth-ish object", () => {
|
|
233
|
+
expect(
|
|
234
|
+
extractOAuthConfig({ clientId: "x", scope: "s", token: "t", buzzer: 9 }),
|
|
235
|
+
).toEqual({ clientId: "x", scope: "s" });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("keeps clientId and clientSecret together", () => {
|
|
239
|
+
expect(
|
|
240
|
+
extractOAuthConfig({ clientId: "cid", clientSecret: "shh" }),
|
|
241
|
+
).toEqual({ clientId: "cid", clientSecret: "shh" });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("keeps grantType, redirectUri, clientName, and authorizationServerUrl", () => {
|
|
245
|
+
expect(
|
|
246
|
+
extractOAuthConfig({
|
|
247
|
+
grantType: "client_credentials",
|
|
248
|
+
clientId: "cid",
|
|
249
|
+
scope: "mcp",
|
|
250
|
+
redirectUri: "http://localhost:4567/callback",
|
|
251
|
+
clientName: "My App",
|
|
252
|
+
authorizationServerUrl: "https://auth.example.com",
|
|
253
|
+
}),
|
|
254
|
+
).toEqual({
|
|
255
|
+
grantType: "client_credentials",
|
|
256
|
+
clientId: "cid",
|
|
257
|
+
scope: "mcp",
|
|
258
|
+
redirectUri: "http://localhost:4567/callback",
|
|
259
|
+
clientName: "My App",
|
|
260
|
+
authorizationServerUrl: "https://auth.example.com",
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("defaults nothing: no known oauth fields → null (plain { token } or {} are not oauth)", () => {
|
|
265
|
+
expect(extractOAuthConfig({ token: "t" })).toBeNull();
|
|
266
|
+
expect(extractOAuthConfig({})).toBeNull();
|
|
267
|
+
expect(extractOAuthConfig({ token: "t", unknown: 1 })).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("returns null when every known field normalizes away (garbage object, invalid grantType alone)", () => {
|
|
271
|
+
expect(extractOAuthConfig({ foo: 1 })).toBeNull();
|
|
272
|
+
expect(extractOAuthConfig({ grantType: "bogus" })).toBeNull();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("drops an invalid grantType while keeping the remaining valid known fields", () => {
|
|
276
|
+
expect(extractOAuthConfig({ grantType: "bogus", clientId: "x" })).toEqual({ clientId: "x" });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("drops unknown-typed/invalid values of known fields", () => {
|
|
280
|
+
// non-string values are dropped, remaining known fields are kept
|
|
281
|
+
expect(extractOAuthConfig({ clientId: 7, scope: "s" })).toEqual({ scope: "s" });
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("getValidToken", () => {
|
|
286
|
+
beforeEach(() => {
|
|
287
|
+
vi.useFakeTimers({ now: FAKE_NOW_MS });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
afterEach(() => {
|
|
291
|
+
vi.useRealTimers();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("returns null when there is no stored entry (no SDK call)", async () => {
|
|
295
|
+
expect(await getValidToken(SERVER_NAME, SERVER_URL, {})).toBeNull();
|
|
296
|
+
expect(sdk.auth).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("returns null when the stored entry has no tokens (no SDK call)", async () => {
|
|
300
|
+
storage.seed(SERVER_NAME, { clientInfo: { clientId: "dc-client" } });
|
|
301
|
+
expect(await getValidToken(SERVER_NAME, SERVER_URL, {})).toBeNull();
|
|
302
|
+
expect(sdk.auth).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("returns the access token when not expired (no SDK call)", async () => {
|
|
306
|
+
storage.seed(SERVER_NAME, { tokens: storedToken(3600) });
|
|
307
|
+
expect(await getValidToken(SERVER_NAME, SERVER_URL, {})).toBe("expired-at");
|
|
308
|
+
expect(sdk.auth).not.toHaveBeenCalled();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("treats a token without expiresAt as never expiring", async () => {
|
|
312
|
+
storage.seed(SERVER_NAME, { tokens: { accessToken: "at-noleak" } });
|
|
313
|
+
expect(await getValidToken(SERVER_NAME, SERVER_URL, {})).toBe("at-noleak");
|
|
314
|
+
expect(sdk.auth).not.toHaveBeenCalled();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("treats expiresAt === now as expired", async () => {
|
|
318
|
+
storage.seed(SERVER_NAME, { tokens: storedToken(0) });
|
|
319
|
+
await getValidToken(SERVER_NAME, SERVER_URL, { clientSecret: "shh" });
|
|
320
|
+
expect(sdk.auth).toHaveBeenCalledTimes(1);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("config-stub guard: expired + clientId without clientSecret → null, no refresh attempt (ADR 0001)", async () => {
|
|
324
|
+
storage.seed(SERVER_NAME, { tokens: storedToken(-100) });
|
|
325
|
+
expect(
|
|
326
|
+
await getValidToken(SERVER_NAME, SERVER_URL, { clientId: "public-client" }),
|
|
327
|
+
).toBeNull();
|
|
328
|
+
expect(sdk.auth).not.toHaveBeenCalled();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("expired + clientSecret → SDK-driven refresh, storage updated, fresh token returned", async () => {
|
|
332
|
+
storage.seed(SERVER_NAME, { tokens: storedToken(-100) });
|
|
333
|
+
|
|
334
|
+
const token = await getValidToken(SERVER_NAME, SERVER_URL, {
|
|
335
|
+
clientId: "cfg-client",
|
|
336
|
+
clientSecret: "shh",
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(token).toBe("refreshed-at");
|
|
340
|
+
expect(sdk.auth).toHaveBeenCalledTimes(1);
|
|
341
|
+
expect(sdk.auth.mock.calls[0]?.[1]).toEqual({ serverUrl: SERVER_URL });
|
|
342
|
+
// The refresh was persisted through the provider's saveTokens
|
|
343
|
+
const saved = storage.entries.get(SERVER_NAME)?.tokens;
|
|
344
|
+
expect(saved?.accessToken).toBe("refreshed-at");
|
|
345
|
+
expect(saved?.expiresAt).toBe(FAKE_NOW_SECONDS + 3600);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("expired + DCR-registered client (no config clientId) → SDK refresh attempted", async () => {
|
|
349
|
+
storage.seed(SERVER_NAME, {
|
|
350
|
+
clientInfo: { clientId: "dc-client", redirectUris: ["http://localhost:19876/callback"] },
|
|
351
|
+
tokens: storedToken(-100),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const token = await getValidToken(SERVER_NAME, SERVER_URL, {});
|
|
355
|
+
|
|
356
|
+
expect(token).toBe("refreshed-at");
|
|
357
|
+
expect(sdk.auth).toHaveBeenCalledTimes(1);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("expired + refresh fails (SDK error) → null", async () => {
|
|
361
|
+
storage.seed(SERVER_NAME, { tokens: storedToken(-100) });
|
|
362
|
+
sdk.auth.mockRejectedValueOnce(new Error("invalid_grant"));
|
|
363
|
+
expect(
|
|
364
|
+
await getValidToken(SERVER_NAME, SERVER_URL, { clientSecret: "shh" }),
|
|
365
|
+
).toBeNull();
|
|
366
|
+
expect(sdk.auth).toHaveBeenCalledTimes(1);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("expired + SDK auth resolved but storage still expired/stale → null", async () => {
|
|
370
|
+
storage.seed(SERVER_NAME, { tokens: storedToken(-100) });
|
|
371
|
+
// SDK "succeeds" without persisting anything (e.g. refresh returned no token)
|
|
372
|
+
sdk.auth.mockImplementationOnce(async () => "AUTHORIZED");
|
|
373
|
+
expect(
|
|
374
|
+
await getValidToken(SERVER_NAME, SERVER_URL, { clientSecret: "shh" }),
|
|
375
|
+
).toBeNull();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("storage read failure (unavailable keyring) → null, never throws", async () => {
|
|
379
|
+
storage.getAuthEntry.mockImplementationOnce(() => {
|
|
380
|
+
throw new Error("OS credential store unavailable — cannot store OAuth tokens securely");
|
|
381
|
+
});
|
|
382
|
+
expect(await getValidToken(SERVER_NAME, SERVER_URL, {})).toBeNull();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe("authenticate — authorization_code", () => {
|
|
387
|
+
it("full happy path: state, dynamic bind, redirect, wait, exchange", async () => {
|
|
388
|
+
const onAuthorizationUrl = vi.fn();
|
|
389
|
+
const pending = authenticate(
|
|
390
|
+
SERVER_NAME,
|
|
391
|
+
SERVER_URL,
|
|
392
|
+
{ grantType: "authorization_code" },
|
|
393
|
+
{ onAuthorizationUrl },
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
// The flow registers a callback waiter for its CSRF state
|
|
397
|
+
await vi.waitFor(() => expect(callback.waiters).toHaveLength(1));
|
|
398
|
+
|
|
399
|
+
// The browser completes the redirect
|
|
400
|
+
callback.waiters[0]?.resolve({ code: "c1" });
|
|
401
|
+
await expect(pending).resolves.toEqual({ status: "authenticated" });
|
|
402
|
+
|
|
403
|
+
// CSRF state: 32 random bytes as hex (64 chars)
|
|
404
|
+
expect(callback.reserveAuthState).toHaveBeenCalledTimes(1);
|
|
405
|
+
const state = callback.reserveAuthState.mock.calls[0]?.[0];
|
|
406
|
+
expect(state).toMatch(/^[0-9a-f]{64}$/);
|
|
407
|
+
|
|
408
|
+
// No fixed redirect URI → default dynamic bind (no strictPort)
|
|
409
|
+
expect(callback.ensureCallbackServer).toHaveBeenCalledTimes(1);
|
|
410
|
+
expect(callback.ensureCallbackServer).toHaveBeenCalledWith();
|
|
411
|
+
|
|
412
|
+
// The provider advertised the ACTUAL bound port (OS-assigned by the
|
|
413
|
+
// mock), not the static default — the authorization redirect must land
|
|
414
|
+
// on the listening server (DCR redirect_uris + redirect_uri).
|
|
415
|
+
const provider = sdk.auth.mock.calls[0]?.[0];
|
|
416
|
+
expect(provider.redirectUrl).toBe("http://localhost:43217/callback");
|
|
417
|
+
expect(provider.clientMetadata.redirect_uris).toEqual(["http://localhost:43217/callback"]);
|
|
418
|
+
|
|
419
|
+
// The waited-on state is the reserved one
|
|
420
|
+
expect(callback.waitForCallback).toHaveBeenCalledTimes(1);
|
|
421
|
+
expect(callback.waitForCallback.mock.calls[0]?.[0]).toBe(state);
|
|
422
|
+
|
|
423
|
+
// The SDK is driven twice with the SAME provider: first the
|
|
424
|
+
// authorization-request phase (no code), then the code exchange
|
|
425
|
+
expect(sdk.auth).toHaveBeenCalledTimes(2);
|
|
426
|
+
const first = sdk.auth.mock.calls[0];
|
|
427
|
+
const second = sdk.auth.mock.calls[1];
|
|
428
|
+
expect(second?.[0]).toBe(first?.[0]);
|
|
429
|
+
expect(first?.[1]).toEqual({ serverUrl: SERVER_URL });
|
|
430
|
+
expect(second?.[1]).toEqual({ serverUrl: SERVER_URL, authorizationCode: "c1" });
|
|
431
|
+
|
|
432
|
+
// The authorization URL was handed to the host's callback
|
|
433
|
+
expect(onAuthorizationUrl).toHaveBeenCalledTimes(1);
|
|
434
|
+
expect(onAuthorizationUrl.mock.calls[0]?.[0]).toBeInstanceOf(URL);
|
|
435
|
+
|
|
436
|
+
// The code exchange persisted a token through provider.saveTokens
|
|
437
|
+
expect(storage.entries.get(SERVER_NAME)?.tokens?.accessToken).toBe("at-c1");
|
|
438
|
+
|
|
439
|
+
// The callback server is a shared singleton — the flow must not stop it
|
|
440
|
+
expect(callback.stopCallbackServer).not.toHaveBeenCalled();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("pre-registered localhost redirectUri → exact-port (strictPort) bind", async () => {
|
|
444
|
+
const pending = authenticate(
|
|
445
|
+
SERVER_NAME,
|
|
446
|
+
SERVER_URL,
|
|
447
|
+
{ grantType: "authorization_code", redirectUri: "http://localhost:4567/callback" },
|
|
448
|
+
);
|
|
449
|
+
await vi.waitFor(() => expect(callback.waiters).toHaveLength(1));
|
|
450
|
+
callback.waiters[0]?.resolve({ code: "c2" });
|
|
451
|
+
await expect(pending).resolves.toEqual({ status: "authenticated" });
|
|
452
|
+
expect(callback.ensureCallbackServer).toHaveBeenCalledWith({ strictPort: true, port: 4567 });
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("pre-registered 127.0.0.1 redirectUri → exact-port bind", async () => {
|
|
456
|
+
const pending = authenticate(
|
|
457
|
+
SERVER_NAME,
|
|
458
|
+
SERVER_URL,
|
|
459
|
+
{
|
|
460
|
+
grantType: "authorization_code",
|
|
461
|
+
redirectUri: "http://127.0.0.1:5000/oauth",
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
await vi.waitFor(() => expect(callback.waiters).toHaveLength(1));
|
|
465
|
+
callback.waiters[0]?.resolve({ code: "c3" });
|
|
466
|
+
await expect(pending).resolves.toEqual({ status: "authenticated" });
|
|
467
|
+
expect(callback.ensureCallbackServer).toHaveBeenCalledWith({ strictPort: true, port: 5000 });
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("remote (non-localhost) redirectUri → default dynamic bind", async () => {
|
|
471
|
+
const pending = authenticate(
|
|
472
|
+
SERVER_NAME,
|
|
473
|
+
SERVER_URL,
|
|
474
|
+
{
|
|
475
|
+
grantType: "authorization_code",
|
|
476
|
+
redirectUri: "https://app.example.com/oauth/callback",
|
|
477
|
+
},
|
|
478
|
+
);
|
|
479
|
+
await vi.waitFor(() => expect(callback.waiters).toHaveLength(1));
|
|
480
|
+
callback.waiters[0]?.resolve({ code: "c4" });
|
|
481
|
+
await expect(pending).resolves.toEqual({ status: "authenticated" });
|
|
482
|
+
expect(callback.ensureCallbackServer).toHaveBeenCalledWith();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("callback server bind failure → needs-interaction (SDK never called)", async () => {
|
|
486
|
+
callback.ensureCallbackServer.mockRejectedValueOnce(
|
|
487
|
+
new Error(
|
|
488
|
+
"Cannot bind OAuth callback server: port 4567 is already in use — stop the other process or change the callback port",
|
|
489
|
+
),
|
|
490
|
+
);
|
|
491
|
+
await expect(
|
|
492
|
+
authenticate(
|
|
493
|
+
SERVER_NAME,
|
|
494
|
+
SERVER_URL,
|
|
495
|
+
{ grantType: "authorization_code", redirectUri: "http://localhost:4567/callback" },
|
|
496
|
+
),
|
|
497
|
+
).resolves.toEqual({ status: "needs-interaction" });
|
|
498
|
+
expect(callback.reserveAuthState).toHaveBeenCalledTimes(1);
|
|
499
|
+
expect(sdk.auth).not.toHaveBeenCalled();
|
|
500
|
+
expect(callback.waitForCallback).not.toHaveBeenCalled();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("callback window timeout → failed (no code exchange)", async () => {
|
|
504
|
+
const pending = authenticate(SERVER_NAME, SERVER_URL, { grantType: "authorization_code" });
|
|
505
|
+
await vi.waitFor(() => expect(callback.waiters).toHaveLength(1));
|
|
506
|
+
callback.waiters[0]?.reject(new Error("OAuth callback timed out"));
|
|
507
|
+
// The failed result carries the underlying cause, not just a generic status
|
|
508
|
+
await expect(pending).resolves.toEqual({
|
|
509
|
+
status: "failed",
|
|
510
|
+
error: "OAuth callback timed out",
|
|
511
|
+
});
|
|
512
|
+
expect(sdk.auth).toHaveBeenCalledTimes(1); // only the authorization-request phase
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("aborted cancel → rethrows the abort error (not 'failed')", async () => {
|
|
516
|
+
const ac = new AbortController();
|
|
517
|
+
const pending = authenticate(SERVER_NAME, SERVER_URL, {}, { signal: ac.signal });
|
|
518
|
+
await vi.waitFor(() => expect(callback.waiters).toHaveLength(1));
|
|
519
|
+
ac.abort();
|
|
520
|
+
callback.waiters[0]?.reject(new Error("OAuth cancelled"));
|
|
521
|
+
await expect(pending).rejects.toThrow("OAuth cancelled");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("SDK error during the authorization-request phase → failed", async () => {
|
|
525
|
+
sdk.auth.mockRejectedValueOnce(new Error("network unreachable"));
|
|
526
|
+
// The failed result carries the SDK error message for the caller to surface
|
|
527
|
+
await expect(
|
|
528
|
+
authenticate(SERVER_NAME, SERVER_URL, { grantType: "authorization_code" }),
|
|
529
|
+
).resolves.toEqual({ status: "failed", error: "network unreachable" });
|
|
530
|
+
expect(callback.waitForCallback).not.toHaveBeenCalled();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("SDK error during the code exchange → failed", async () => {
|
|
534
|
+
// First phase (authorization request) succeeds, the exchange fails
|
|
535
|
+
sdk.auth
|
|
536
|
+
.mockImplementationOnce(sdkAuthDefaultImpl)
|
|
537
|
+
.mockRejectedValueOnce(new Error("invalid_grant"));
|
|
538
|
+
const pending = authenticate(SERVER_NAME, SERVER_URL, { grantType: "authorization_code" });
|
|
539
|
+
await vi.waitFor(() => expect(callback.waiters).toHaveLength(1));
|
|
540
|
+
callback.waiters[0]?.resolve({ code: "c5" });
|
|
541
|
+
// The failed result carries the SDK error message for the caller to surface
|
|
542
|
+
await expect(pending).resolves.toEqual({ status: "failed", error: "invalid_grant" });
|
|
543
|
+
expect(sdk.auth).toHaveBeenCalledTimes(2);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
describe("authenticate — client_credentials", () => {
|
|
548
|
+
it("single non-interactive SDK call, no callback server, authenticated", async () => {
|
|
549
|
+
const result = await authenticate(SERVER_NAME, SERVER_URL, {
|
|
550
|
+
grantType: "client_credentials",
|
|
551
|
+
clientId: "m2m-client",
|
|
552
|
+
clientSecret: "m2m-secret",
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
expect(result).toEqual({ status: "authenticated" });
|
|
556
|
+
expect(sdk.auth).toHaveBeenCalledTimes(1);
|
|
557
|
+
expect(sdk.auth.mock.calls[0]?.[1]).toEqual({ serverUrl: SERVER_URL });
|
|
558
|
+
expect(callback.waitForCallback).not.toHaveBeenCalled();
|
|
559
|
+
expect(callback.ensureCallbackServer).not.toHaveBeenCalled();
|
|
560
|
+
expect(callback.reserveAuthState).not.toHaveBeenCalled();
|
|
561
|
+
// The SDK fetched the token directly (saveTokens through the provider)
|
|
562
|
+
expect(storage.entries.get(SERVER_NAME)?.tokens?.accessToken).toBe("cc-at");
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("SDK error → failed", async () => {
|
|
566
|
+
sdk.auth.mockRejectedValueOnce(new Error("invalid_client"));
|
|
567
|
+
// The failed result carries the SDK error message for the caller to surface
|
|
568
|
+
await expect(
|
|
569
|
+
authenticate(SERVER_NAME, SERVER_URL, { grantType: "client_credentials" }),
|
|
570
|
+
).resolves.toEqual({ status: "failed", error: "invalid_client" });
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("already-aborted signal → rethrows without any SDK call", async () => {
|
|
574
|
+
const ac = new AbortController();
|
|
575
|
+
ac.abort();
|
|
576
|
+
await expect(
|
|
577
|
+
authenticate(SERVER_NAME, SERVER_URL, { grantType: "client_credentials" }, {
|
|
578
|
+
signal: ac.signal,
|
|
579
|
+
}),
|
|
580
|
+
).rejects.toThrow("OAuth cancelled");
|
|
581
|
+
expect(sdk.auth).not.toHaveBeenCalled();
|
|
582
|
+
});
|
|
583
|
+
});
|