offrouter-core 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/auth/credential-chain.test.ts +124 -0
- package/src/auth/credential-chain.ts +55 -4
- package/src/auth/index.ts +14 -1
- package/src/auth/keychain.test.ts +154 -1
- package/src/auth/keychain.ts +111 -0
- package/src/auth/oauth-pkce.test.ts +147 -2
- package/src/auth/oauth-pkce.ts +175 -13
- package/src/auth/oauth-server.test.ts +83 -0
- package/src/auth/oauth-server.ts +296 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +4 -0
- package/src/index.ts +17 -0
- package/src/usage-file.test.ts +243 -0
- package/src/usage-file.ts +435 -0
- package/src/usage.ts +44 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "offrouter-core",
|
|
3
|
-
"version": "0.1
|
|
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": "
|
|
19
|
-
"zod": "
|
|
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 {
|
|
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
|
}
|