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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offrouter-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -15,7 +15,7 @@
15
15
  "test": "vitest run"
16
16
  },
17
17
  "dependencies": {
18
- "smol-toml": "0.0.0",
19
- "zod": "0.0.0"
18
+ "smol-toml": "^1.7.0",
19
+ "zod": "^3.23.0"
20
20
  }
21
21
  }
@@ -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 { accountStorageKey } from "./keychain.js";
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 is never auto-triggered during credential resolution.
122
- // It requires explicit configuration and a separate flow.
123
- return null;
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 { getProviderToken, setProviderToken } from "./keychain.js";
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 { getProviderToken, setProviderToken } from "./keychain.js";
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
  });
@@ -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
  }