offrouter-core 0.1.0 → 0.2.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/package.json +1 -1
- 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
package/package.json
CHANGED
|
@@ -199,4 +199,128 @@ describe("credentialSource type narrowing", () => {
|
|
|
199
199
|
const s: CredentialSource = { type: "oauth", provider: "anthropic" };
|
|
200
200
|
expect(s.type).toBe("oauth");
|
|
201
201
|
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("oauth credential source", () => {
|
|
205
|
+
function storeWithOAuth(
|
|
206
|
+
accessToken: string,
|
|
207
|
+
refreshToken?: string,
|
|
208
|
+
expiresAt?: number,
|
|
209
|
+
): SecretStore {
|
|
210
|
+
const tokens = {
|
|
211
|
+
accessToken,
|
|
212
|
+
refreshToken,
|
|
213
|
+
expiresAt,
|
|
214
|
+
obtainedAt: 1_000,
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
get: vi.fn(async (id: string) =>
|
|
218
|
+
id === "anthropic:oauth" ? JSON.stringify(tokens) : undefined,
|
|
219
|
+
),
|
|
220
|
+
set: vi.fn(),
|
|
221
|
+
delete: vi.fn(),
|
|
222
|
+
listProviders: vi.fn(async () => ["anthropic:oauth"]),
|
|
223
|
+
redact: vi.fn((v: string) => v),
|
|
224
|
+
toJSON: vi.fn(() => ({})),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
it("resolves a stored (non-expired) OAuth token", async () => {
|
|
229
|
+
const store = storeWithOAuth(
|
|
230
|
+
"ant-oauth-token",
|
|
231
|
+
"ant-refresh",
|
|
232
|
+
Date.now() + 3600_000,
|
|
233
|
+
);
|
|
234
|
+
const sources: CredentialSource[] = [
|
|
235
|
+
{ type: "oauth", provider: "anthropic" },
|
|
236
|
+
];
|
|
237
|
+
const result = await resolveCredential(sources, { store });
|
|
238
|
+
expect(result).not.toBeNull();
|
|
239
|
+
expect(result!.value.rawValue).toBe("ant-oauth-token");
|
|
240
|
+
expect(result!.source).toBe("oauth");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("returns null when no OAuth tokens are stored", async () => {
|
|
244
|
+
const store = stubStore({});
|
|
245
|
+
const sources: CredentialSource[] = [
|
|
246
|
+
{ type: "oauth", provider: "anthropic" },
|
|
247
|
+
];
|
|
248
|
+
const result = await resolveCredential(sources, { store });
|
|
249
|
+
expect(result).toBeNull();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("returns null for an expired token without a refresh callback", async () => {
|
|
253
|
+
const store = storeWithOAuth(
|
|
254
|
+
"ant-oauth-expired",
|
|
255
|
+
"ant-refresh",
|
|
256
|
+
Date.now() - 1000,
|
|
257
|
+
);
|
|
258
|
+
const sources: CredentialSource[] = [
|
|
259
|
+
{ type: "oauth", provider: "anthropic" },
|
|
260
|
+
];
|
|
261
|
+
const result = await resolveCredential(sources, { store });
|
|
262
|
+
expect(result).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("calls refreshOAuthToken for expired tokens when callback is provided", async () => {
|
|
266
|
+
const store = storeWithOAuth(
|
|
267
|
+
"ant-oauth-expired",
|
|
268
|
+
"ant-refresh",
|
|
269
|
+
Date.now() - 1000,
|
|
270
|
+
);
|
|
271
|
+
const refreshCallback = vi.fn(async (provider: string) => {
|
|
272
|
+
expect(provider).toBe("anthropic");
|
|
273
|
+
return "ant-oauth-refreshed";
|
|
274
|
+
});
|
|
275
|
+
const sources: CredentialSource[] = [
|
|
276
|
+
{ type: "oauth", provider: "anthropic" },
|
|
277
|
+
];
|
|
278
|
+
const result = await resolveCredential(sources, {
|
|
279
|
+
store,
|
|
280
|
+
refreshOAuthToken: refreshCallback,
|
|
281
|
+
});
|
|
282
|
+
expect(result).not.toBeNull();
|
|
283
|
+
expect(result!.value.rawValue).toBe("ant-oauth-refreshed");
|
|
284
|
+
expect(result!.source).toBe("oauth");
|
|
285
|
+
expect(refreshCallback).toHaveBeenCalledWith("anthropic");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("falls through when refreshOAuthToken returns null", async () => {
|
|
289
|
+
const store = storeWithOAuth(
|
|
290
|
+
"ant-oauth-expired",
|
|
291
|
+
"ant-refresh",
|
|
292
|
+
Date.now() - 1000,
|
|
293
|
+
);
|
|
294
|
+
const refreshCallback = vi.fn(async () => null);
|
|
295
|
+
const sources: CredentialSource[] = [
|
|
296
|
+
{ type: "oauth", provider: "anthropic" },
|
|
297
|
+
{ type: "keychain", providerId: "fallback" },
|
|
298
|
+
];
|
|
299
|
+
// No fallback token stored, so overall null.
|
|
300
|
+
const result = await resolveCredential(sources, {
|
|
301
|
+
store,
|
|
302
|
+
refreshOAuthToken: refreshCallback,
|
|
303
|
+
});
|
|
304
|
+
expect(result).toBeNull();
|
|
305
|
+
expect(refreshCallback).toHaveBeenCalledWith("anthropic");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("falls through when refreshOAuthToken throws", async () => {
|
|
309
|
+
const store = storeWithOAuth(
|
|
310
|
+
"ant-oauth-expired",
|
|
311
|
+
"ant-refresh",
|
|
312
|
+
Date.now() - 1000,
|
|
313
|
+
);
|
|
314
|
+
const refreshCallback = vi.fn(async () => {
|
|
315
|
+
throw new Error("network error");
|
|
316
|
+
});
|
|
317
|
+
const sources: CredentialSource[] = [
|
|
318
|
+
{ type: "oauth", provider: "anthropic" },
|
|
319
|
+
];
|
|
320
|
+
const result = await resolveCredential(sources, {
|
|
321
|
+
store,
|
|
322
|
+
refreshOAuthToken: refreshCallback,
|
|
323
|
+
});
|
|
324
|
+
expect(result).toBeNull();
|
|
325
|
+
});
|
|
202
326
|
});
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { SecretStore } from "../secrets.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
accountStorageKey,
|
|
12
|
+
getOAuthTokens,
|
|
13
|
+
isOAuthTokenExpired,
|
|
14
|
+
} from "./keychain.js";
|
|
11
15
|
|
|
12
16
|
// ---------------------------------------------------------------------------
|
|
13
17
|
// Redacted wrapper
|
|
@@ -80,6 +84,18 @@ export interface ResolveCredentialOptions {
|
|
|
80
84
|
* user's OffRouter home directory.
|
|
81
85
|
*/
|
|
82
86
|
config?: Record<string, unknown>;
|
|
87
|
+
/**
|
|
88
|
+
* Injectable fetch for any network calls during credential resolution
|
|
89
|
+
* (e.g. OAuth token refresh). Defaults to globalThis.fetch.
|
|
90
|
+
*/
|
|
91
|
+
fetch?: typeof globalThis.fetch;
|
|
92
|
+
/**
|
|
93
|
+
* Optional OAuth token refresher. Given a provider id, refreshes the stored
|
|
94
|
+
* OAuth token (if possible) and returns the new access token, or null if
|
|
95
|
+
* refresh is not possible. When omitted, expired OAuth tokens resolve as
|
|
96
|
+
* null (no silent refresh during credential resolution).
|
|
97
|
+
*/
|
|
98
|
+
refreshOAuthToken?: (provider: string) => Promise<string | null>;
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
/**
|
|
@@ -118,9 +134,14 @@ async function trySource(
|
|
|
118
134
|
return tryKeychain(source.providerId, opts.store, source.accountId);
|
|
119
135
|
}
|
|
120
136
|
case "oauth": {
|
|
121
|
-
// OAuth
|
|
122
|
-
//
|
|
123
|
-
|
|
137
|
+
// Resolve stored OAuth tokens. This does NOT trigger a login flow;
|
|
138
|
+
// it resolves tokens already obtained via the `login` command.
|
|
139
|
+
// If the token is expired and a refresher is wired, try refreshing.
|
|
140
|
+
return tryOAuth(
|
|
141
|
+
source.provider,
|
|
142
|
+
opts.store,
|
|
143
|
+
opts.refreshOAuthToken,
|
|
144
|
+
);
|
|
124
145
|
}
|
|
125
146
|
}
|
|
126
147
|
}
|
|
@@ -181,4 +202,34 @@ async function tryKeychain(
|
|
|
181
202
|
// KeychainUnavailableError or other errors: treat as missing.
|
|
182
203
|
}
|
|
183
204
|
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Resolve a credential from stored OAuth tokens (no new login flow).
|
|
209
|
+
* Returns the access token if valid, triggers refresh if expired + wired,
|
|
210
|
+
* or returns null if no tokens exist or refresh fails.
|
|
211
|
+
*/
|
|
212
|
+
async function tryOAuth(
|
|
213
|
+
providerId: string,
|
|
214
|
+
store: SecretStore,
|
|
215
|
+
refresh?: (provider: string) => Promise<string | null>,
|
|
216
|
+
): Promise<string | null> {
|
|
217
|
+
try {
|
|
218
|
+
const tokens = await getOAuthTokens(providerId, store);
|
|
219
|
+
if (!tokens) return null;
|
|
220
|
+
|
|
221
|
+
if (!isOAuthTokenExpired(tokens)) {
|
|
222
|
+
return tokens.accessToken;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Expired: try refresh if a refresher is wired.
|
|
226
|
+
if (!refresh) return null;
|
|
227
|
+
try {
|
|
228
|
+
return await refresh(providerId);
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
184
235
|
}
|
package/src/auth/index.ts
CHANGED
|
@@ -11,15 +11,26 @@ export type {
|
|
|
11
11
|
ResolveCredentialOptions,
|
|
12
12
|
} from "./credential-chain.js";
|
|
13
13
|
|
|
14
|
-
export {
|
|
14
|
+
export {
|
|
15
|
+
deleteOAuthTokens,
|
|
16
|
+
getOAuthTokens,
|
|
17
|
+
getProviderToken,
|
|
18
|
+
isOAuthTokenExpired,
|
|
19
|
+
setOAuthTokens,
|
|
20
|
+
setProviderToken,
|
|
21
|
+
} from "./keychain.js";
|
|
22
|
+
export type { OAuthStoredTokens } from "./keychain.js";
|
|
15
23
|
|
|
16
24
|
export {
|
|
25
|
+
defaultCallbackPort,
|
|
17
26
|
exchangeCodeForTokens,
|
|
18
27
|
generateCodeChallenge,
|
|
19
28
|
generateCodeVerifier,
|
|
20
29
|
isOAuthProvider,
|
|
21
30
|
PKCE_CONFIGS,
|
|
22
31
|
refreshTokens,
|
|
32
|
+
resolveClientId,
|
|
33
|
+
runOAuthLogin,
|
|
23
34
|
startOAuthFlow,
|
|
24
35
|
} from "./oauth-pkce.js";
|
|
25
36
|
export type {
|
|
@@ -29,4 +40,6 @@ export type {
|
|
|
29
40
|
OAuthStartOptions,
|
|
30
41
|
OAuthTokenOptions,
|
|
31
42
|
OAuthTokenResponse,
|
|
43
|
+
RunOAuthLoginOptions,
|
|
44
|
+
RunOAuthLoginResult,
|
|
32
45
|
} from "./oauth-pkce.js";
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
* Tests for keychain-backed token helpers.
|
|
3
3
|
*/
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
deleteOAuthTokens,
|
|
7
|
+
getOAuthTokens,
|
|
8
|
+
getProviderToken,
|
|
9
|
+
isOAuthTokenExpired,
|
|
10
|
+
setOAuthTokens,
|
|
11
|
+
setProviderToken,
|
|
12
|
+
type OAuthStoredTokens,
|
|
13
|
+
} from "./keychain.js";
|
|
6
14
|
import { FileSecretStore } from "../secrets.js";
|
|
7
15
|
import { tmpdir } from "node:os";
|
|
8
16
|
import { join } from "node:path";
|
|
@@ -109,4 +117,149 @@ describe("multi-account tokens", () => {
|
|
|
109
117
|
rmSync(dir, { recursive: true, force: true });
|
|
110
118
|
}
|
|
111
119
|
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("OAuth token storage", () => {
|
|
123
|
+
function freshStore() {
|
|
124
|
+
const dir = mkdtempSync(join(tmpdir(), "offrouter-oauth-test-"));
|
|
125
|
+
const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
|
|
126
|
+
return { store, dir };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
it("getOAuthTokens returns undefined when nothing is stored", async () => {
|
|
130
|
+
const { store, dir } = freshStore();
|
|
131
|
+
try {
|
|
132
|
+
const result = await getOAuthTokens("anthropic", store);
|
|
133
|
+
expect(result).toBeUndefined();
|
|
134
|
+
} finally {
|
|
135
|
+
rmSync(dir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("stores and retrieves structured OAuth tokens", async () => {
|
|
140
|
+
const { store, dir } = freshStore();
|
|
141
|
+
try {
|
|
142
|
+
const tokens: OAuthStoredTokens = {
|
|
143
|
+
accessToken: "access-abc",
|
|
144
|
+
refreshToken: "refresh-xyz",
|
|
145
|
+
expiresAt: 9_999_999_999_000,
|
|
146
|
+
scope: "api offline_access",
|
|
147
|
+
obtainedAt: 1_000,
|
|
148
|
+
};
|
|
149
|
+
await setOAuthTokens("anthropic", tokens, store);
|
|
150
|
+
const result = await getOAuthTokens("anthropic", store);
|
|
151
|
+
expect(result).toEqual(tokens);
|
|
152
|
+
} finally {
|
|
153
|
+
rmSync(dir, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("stores tokens under a separate key from plain API-key tokens", async () => {
|
|
158
|
+
const { store, dir } = freshStore();
|
|
159
|
+
try {
|
|
160
|
+
await setProviderToken("anthropic", "sk-ant-apikey", store);
|
|
161
|
+
await setOAuthTokens(
|
|
162
|
+
"anthropic",
|
|
163
|
+
{ accessToken: "access-oauth", obtainedAt: 1 },
|
|
164
|
+
store,
|
|
165
|
+
);
|
|
166
|
+
const apiToken = await getProviderToken("anthropic", store);
|
|
167
|
+
const oauthTokens = await getOAuthTokens("anthropic", store);
|
|
168
|
+
expect(apiToken!.rawValue).toBe("sk-ant-apikey");
|
|
169
|
+
expect(oauthTokens!.accessToken).toBe("access-oauth");
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(dir, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("deleteOAuthTokens removes only the OAuth entry", async () => {
|
|
176
|
+
const { store, dir } = freshStore();
|
|
177
|
+
try {
|
|
178
|
+
await setOAuthTokens(
|
|
179
|
+
"openai",
|
|
180
|
+
{ accessToken: "a", obtainedAt: 1 },
|
|
181
|
+
store,
|
|
182
|
+
);
|
|
183
|
+
await setProviderToken("openai", "sk-openai", store);
|
|
184
|
+
await deleteOAuthTokens("openai", store);
|
|
185
|
+
expect(await getOAuthTokens("openai", store)).toBeUndefined();
|
|
186
|
+
expect((await getProviderToken("openai", store))!.rawValue).toBe(
|
|
187
|
+
"sk-openai",
|
|
188
|
+
);
|
|
189
|
+
} finally {
|
|
190
|
+
rmSync(dir, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("supports per-account OAuth token storage", async () => {
|
|
195
|
+
const { store, dir } = freshStore();
|
|
196
|
+
try {
|
|
197
|
+
await setOAuthTokens(
|
|
198
|
+
"anthropic",
|
|
199
|
+
{ accessToken: "default", obtainedAt: 1 },
|
|
200
|
+
store,
|
|
201
|
+
);
|
|
202
|
+
await setOAuthTokens(
|
|
203
|
+
"anthropic",
|
|
204
|
+
{ accessToken: "acct2", obtainedAt: 1 },
|
|
205
|
+
store,
|
|
206
|
+
"anthropic-2",
|
|
207
|
+
);
|
|
208
|
+
const def = await getOAuthTokens("anthropic", store);
|
|
209
|
+
const acct2 = await getOAuthTokens("anthropic", store, "anthropic-2");
|
|
210
|
+
expect(def!.accessToken).toBe("default");
|
|
211
|
+
expect(acct2!.accessToken).toBe("acct2");
|
|
212
|
+
} finally {
|
|
213
|
+
rmSync(dir, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns undefined for corrupt stored JSON", async () => {
|
|
218
|
+
const { store, dir } = freshStore();
|
|
219
|
+
try {
|
|
220
|
+
await store.set("anthropic:oauth", "{not json");
|
|
221
|
+
const result = await getOAuthTokens("anthropic", store);
|
|
222
|
+
expect(result).toBeUndefined();
|
|
223
|
+
} finally {
|
|
224
|
+
rmSync(dir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("isOAuthTokenExpired", () => {
|
|
230
|
+
it("treats tokens without expiresAt as not expired", () => {
|
|
231
|
+
expect(
|
|
232
|
+
isOAuthTokenExpired({ accessToken: "a", obtainedAt: 1 }),
|
|
233
|
+
).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("treats future expiry as valid", () => {
|
|
237
|
+
expect(
|
|
238
|
+
isOAuthTokenExpired(
|
|
239
|
+
{ accessToken: "a", expiresAt: 10_000, obtainedAt: 1 },
|
|
240
|
+
5_000,
|
|
241
|
+
1_000,
|
|
242
|
+
),
|
|
243
|
+
).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("treats tokens within the skew window as expired", () => {
|
|
247
|
+
// now=5000, skew=1000 => anything expiring <= 6000 is expired
|
|
248
|
+
expect(
|
|
249
|
+
isOAuthTokenExpired(
|
|
250
|
+
{ accessToken: "a", expiresAt: 6_000, obtainedAt: 1 },
|
|
251
|
+
5_000,
|
|
252
|
+
1_000,
|
|
253
|
+
),
|
|
254
|
+
).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("treats past expiry as expired", () => {
|
|
258
|
+
expect(
|
|
259
|
+
isOAuthTokenExpired(
|
|
260
|
+
{ accessToken: "a", expiresAt: 1_000, obtainedAt: 0 },
|
|
261
|
+
5_000,
|
|
262
|
+
),
|
|
263
|
+
).toBe(true);
|
|
264
|
+
});
|
|
112
265
|
});
|
package/src/auth/keychain.ts
CHANGED
|
@@ -54,4 +54,115 @@ export async function setProviderToken(
|
|
|
54
54
|
): Promise<void> {
|
|
55
55
|
const key = accountStorageKey(providerId, accountId);
|
|
56
56
|
await store.set(key, token);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// OAuth token storage (structured, separate from API-key tokens)
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** Suffix that namespaces structured OAuth token blobs in the secret store. */
|
|
64
|
+
const OAUTH_SUFFIX = ":oauth";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Structured OAuth token material stored under "${providerId}[:account]:oauth".
|
|
68
|
+
* accessToken is the bearer used for provider calls; refreshToken drives
|
|
69
|
+
* silent refresh; expiresAt/obtainedAt are epoch milliseconds. Stored as JSON
|
|
70
|
+
* so the whole token set moves atomically and stays separate from API keys.
|
|
71
|
+
*/
|
|
72
|
+
export interface OAuthStoredTokens {
|
|
73
|
+
accessToken: string;
|
|
74
|
+
refreshToken?: string;
|
|
75
|
+
expiresAt?: number;
|
|
76
|
+
scope?: string;
|
|
77
|
+
obtainedAt: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Compute the secret-store key for a provider+account OAuth token set. */
|
|
81
|
+
export function oauthStorageKey(
|
|
82
|
+
providerId: string,
|
|
83
|
+
accountId?: string,
|
|
84
|
+
): string {
|
|
85
|
+
return `${accountStorageKey(providerId, accountId)}${OAUTH_SUFFIX}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Store structured OAuth tokens (access, optional refresh, expiry, scope).
|
|
90
|
+
*/
|
|
91
|
+
export async function setOAuthTokens(
|
|
92
|
+
providerId: string,
|
|
93
|
+
tokens: OAuthStoredTokens,
|
|
94
|
+
store: SecretStore,
|
|
95
|
+
accountId?: string,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const key = oauthStorageKey(providerId, accountId);
|
|
98
|
+
await store.set(key, JSON.stringify(tokens));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Retrieve structured OAuth tokens. Returns undefined if nothing is stored,
|
|
103
|
+
* the store is unavailable, or the stored payload is corrupt.
|
|
104
|
+
*/
|
|
105
|
+
export async function getOAuthTokens(
|
|
106
|
+
providerId: string,
|
|
107
|
+
store: SecretStore,
|
|
108
|
+
accountId?: string,
|
|
109
|
+
): Promise<OAuthStoredTokens | undefined> {
|
|
110
|
+
try {
|
|
111
|
+
const key = oauthStorageKey(providerId, accountId);
|
|
112
|
+
const raw = await store.get(key);
|
|
113
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
const parsed = JSON.parse(raw) as Partial<OAuthStoredTokens>;
|
|
117
|
+
if (
|
|
118
|
+
typeof parsed.accessToken !== "string" ||
|
|
119
|
+
parsed.accessToken.length === 0
|
|
120
|
+
) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
accessToken: parsed.accessToken,
|
|
125
|
+
refreshToken:
|
|
126
|
+
typeof parsed.refreshToken === "string" && parsed.refreshToken.length > 0
|
|
127
|
+
? parsed.refreshToken
|
|
128
|
+
: undefined,
|
|
129
|
+
expiresAt:
|
|
130
|
+
typeof parsed.expiresAt === "number" ? parsed.expiresAt : undefined,
|
|
131
|
+
scope: typeof parsed.scope === "string" ? parsed.scope : undefined,
|
|
132
|
+
obtainedAt:
|
|
133
|
+
typeof parsed.obtainedAt === "number" ? parsed.obtainedAt : Date.now(),
|
|
134
|
+
};
|
|
135
|
+
} catch {
|
|
136
|
+
// Corrupt payload or unavailable store: treat as missing.
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Remove stored OAuth tokens for a provider+account. */
|
|
142
|
+
export async function deleteOAuthTokens(
|
|
143
|
+
providerId: string,
|
|
144
|
+
store: SecretStore,
|
|
145
|
+
accountId?: string,
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
try {
|
|
148
|
+
const key = oauthStorageKey(providerId, accountId);
|
|
149
|
+
await store.delete(key);
|
|
150
|
+
} catch {
|
|
151
|
+
// Unavailable store: nothing to delete.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Returns true when the access token is expired or within the skew window.
|
|
157
|
+
* Tokens without an expiresAt are treated as not expired (no expiry known).
|
|
158
|
+
*/
|
|
159
|
+
export function isOAuthTokenExpired(
|
|
160
|
+
tokens: OAuthStoredTokens,
|
|
161
|
+
now: number = Date.now(),
|
|
162
|
+
skewMs = 60_000,
|
|
163
|
+
): boolean {
|
|
164
|
+
if (typeof tokens.expiresAt !== "number") {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
return now + skewMs >= tokens.expiresAt;
|
|
57
168
|
}
|
|
@@ -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
|
+
});
|