offrouter-core 0.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offrouter-core",
3
- "version": "0.0.0",
3
+ "version": "0.2.0",
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": "^1.7.0",
19
- "zod": "^3.23.0"
18
+ "smol-toml": "0.0.0",
19
+ "zod": "0.0.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,6 +7,11 @@
7
7
  */
8
8
 
9
9
  import type { SecretStore } from "../secrets.js";
10
+ import {
11
+ accountStorageKey,
12
+ getOAuthTokens,
13
+ isOAuthTokenExpired,
14
+ } from "./keychain.js";
10
15
 
11
16
  // ---------------------------------------------------------------------------
12
17
  // Redacted wrapper
@@ -51,7 +56,7 @@ export class Redacted<T extends string = string> {
51
56
  export type CredentialSource =
52
57
  | { type: "env"; name: string }
53
58
  | { type: "config"; key: string }
54
- | { type: "keychain"; providerId: string }
59
+ | { type: "keychain"; providerId: string; accountId?: string }
55
60
  | { type: "oauth"; provider: string };
56
61
 
57
62
  /**
@@ -79,6 +84,18 @@ export interface ResolveCredentialOptions {
79
84
  * user's OffRouter home directory.
80
85
  */
81
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>;
82
99
  }
83
100
 
84
101
  /**
@@ -114,12 +131,17 @@ async function trySource(
114
131
  return tryConfig(source.key, opts.config);
115
132
  }
116
133
  case "keychain": {
117
- return tryKeychain(source.providerId, opts.store);
134
+ return tryKeychain(source.providerId, opts.store, source.accountId);
118
135
  }
119
136
  case "oauth": {
120
- // OAuth is never auto-triggered during credential resolution.
121
- // It requires explicit configuration and a separate flow.
122
- 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
+ );
123
145
  }
124
146
  }
125
147
  }
@@ -168,9 +190,11 @@ function tryConfig(key: string, config?: Record<string, unknown>): string | null
168
190
  async function tryKeychain(
169
191
  providerId: string,
170
192
  store: SecretStore,
193
+ accountId?: string,
171
194
  ): Promise<string | null> {
172
195
  try {
173
- const value = await store.get(providerId);
196
+ const key = accountStorageKey(providerId, accountId);
197
+ const value = await store.get(key);
174
198
  if (typeof value === "string" && value.trim().length > 0) {
175
199
  return value.trim();
176
200
  }
@@ -178,4 +202,34 @@ async function tryKeychain(
178
202
  // KeychainUnavailableError or other errors: treat as missing.
179
203
  }
180
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
+ }
181
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";
@@ -72,4 +80,186 @@ describe("setProviderToken", () => {
72
80
  rmSync(dir, { recursive: true, force: true });
73
81
  }
74
82
  });
83
+ });
84
+
85
+ describe("multi-account tokens", () => {
86
+ it("stores and retrieves per-account tokens with backward compatibility", async () => {
87
+ const dir = mkdtempSync(join(tmpdir(), "offrouter-keychain-test-"));
88
+ try {
89
+ const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
90
+ await setProviderToken("anthropic", "tok-legacy", store);
91
+ await setProviderToken("anthropic", "tok-2", store, "anthropic-2");
92
+
93
+ const legacy = await getProviderToken("anthropic", store);
94
+ expect(legacy!.rawValue).toBe("tok-legacy");
95
+
96
+ const acct2 = await getProviderToken("anthropic", store, "anthropic-2");
97
+ expect(acct2!.rawValue).toBe("tok-2");
98
+
99
+ // accountId === providerId uses legacy key
100
+ const explicitDefault = await getProviderToken("anthropic", store, "anthropic");
101
+ expect(explicitDefault!.rawValue).toBe("tok-legacy");
102
+ } finally {
103
+ rmSync(dir, { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ it("setProviderToken with accountId matching providerId overwrites legacy slot", async () => {
108
+ const dir = mkdtempSync(join(tmpdir(), "offrouter-keychain-test-"));
109
+ try {
110
+ const store = new FileSecretStore({ filePath: join(dir, "secrets.json") });
111
+ await setProviderToken("anthropic", "tok-legacy", store);
112
+ await setProviderToken("anthropic", "tok-implicit", store, "anthropic");
113
+
114
+ const result = await getProviderToken("anthropic", store);
115
+ expect(result!.rawValue).toBe("tok-implicit");
116
+ } finally {
117
+ rmSync(dir, { recursive: true, force: true });
118
+ }
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
+ });
75
265
  });
@@ -3,10 +3,24 @@
3
3
  *
4
4
  * Provides getProviderToken/setProviderToken that use the SecretStore
5
5
  * abstraction (file or OS keychain). Never scrapes other CLI credential stores.
6
+ *
7
+ * Per-account token storage: when accountId is provided and differs from
8
+ * providerId, the storage key is "${providerId}:${accountId}". Otherwise the
9
+ * plain providerId is used for backward compatibility with existing tokens.
6
10
  */
7
11
  import type { SecretStore } from "../secrets.js";
8
12
  import { Redacted } from "./credential-chain.js";
9
13
 
14
+ /**
15
+ * Compute the storage key for a provider+account combination.
16
+ * Backward compatible: when accountId is absent or equals providerId, returns
17
+ * the plain providerId so existing tokens resolve.
18
+ */
19
+ export function accountStorageKey(providerId: string, accountId?: string): string {
20
+ if (!accountId || accountId === providerId) return providerId;
21
+ return `${providerId}:${accountId}`;
22
+ }
23
+
10
24
  /**
11
25
  * Retrieve a provider token from the secret store.
12
26
  * Returns undefined if no token is stored or the store is unavailable.
@@ -14,9 +28,11 @@ import { Redacted } from "./credential-chain.js";
14
28
  export async function getProviderToken(
15
29
  providerId: string,
16
30
  store: SecretStore,
31
+ accountId?: string,
17
32
  ): Promise<Redacted<string> | undefined> {
18
33
  try {
19
- const value = await store.get(providerId);
34
+ const key = accountStorageKey(providerId, accountId);
35
+ const value = await store.get(key);
20
36
  if (typeof value === "string" && value.length > 0) {
21
37
  return new Redacted(value);
22
38
  }
@@ -34,6 +50,119 @@ export async function setProviderToken(
34
50
  providerId: string,
35
51
  token: string,
36
52
  store: SecretStore,
53
+ accountId?: string,
37
54
  ): Promise<void> {
38
- await store.set(providerId, token);
55
+ const key = accountStorageKey(providerId, accountId);
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;
39
168
  }