@providerkit/core 0.17.2 → 0.20.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.
Files changed (68) hide show
  1. package/README.md +1 -0
  2. package/dist/auth/device.d.ts +34 -0
  3. package/dist/auth/device.d.ts.map +1 -0
  4. package/dist/auth/device.js +85 -0
  5. package/dist/auth/device.js.map +1 -0
  6. package/dist/auth/flows.d.ts +36 -0
  7. package/dist/auth/flows.d.ts.map +1 -0
  8. package/dist/auth/flows.js +392 -0
  9. package/dist/auth/flows.js.map +1 -0
  10. package/dist/auth/index.d.ts +5 -0
  11. package/dist/auth/index.d.ts.map +1 -0
  12. package/dist/auth/index.js +8 -0
  13. package/dist/auth/index.js.map +1 -0
  14. package/dist/auth/oauth.d.ts +92 -0
  15. package/dist/auth/oauth.d.ts.map +1 -0
  16. package/dist/auth/oauth.js +148 -0
  17. package/dist/auth/oauth.js.map +1 -0
  18. package/dist/auth/token-source.d.ts +30 -0
  19. package/dist/auth/token-source.d.ts.map +1 -0
  20. package/dist/auth/token-source.js +61 -0
  21. package/dist/auth/token-source.js.map +1 -0
  22. package/dist/context.d.ts.map +1 -1
  23. package/dist/context.js +17 -1
  24. package/dist/context.js.map +1 -1
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +3 -0
  27. package/dist/errors.js.map +1 -1
  28. package/dist/presets.d.ts +32 -3
  29. package/dist/presets.d.ts.map +1 -1
  30. package/dist/presets.js +24 -2
  31. package/dist/presets.js.map +1 -1
  32. package/dist/providers/anthropic.d.ts.map +1 -1
  33. package/dist/providers/anthropic.js +6 -1
  34. package/dist/providers/anthropic.js.map +1 -1
  35. package/dist/providers/factory.d.ts +8 -2
  36. package/dist/providers/factory.d.ts.map +1 -1
  37. package/dist/providers/factory.js +11 -4
  38. package/dist/providers/factory.js.map +1 -1
  39. package/dist/providers/gemini.d.ts.map +1 -1
  40. package/dist/providers/gemini.js +3 -0
  41. package/dist/providers/gemini.js.map +1 -1
  42. package/dist/providers/openai.d.ts +7 -1
  43. package/dist/providers/openai.d.ts.map +1 -1
  44. package/dist/providers/openai.js +6 -0
  45. package/dist/providers/openai.js.map +1 -1
  46. package/dist/providers/responses.d.ts +7 -1
  47. package/dist/providers/responses.d.ts.map +1 -1
  48. package/dist/providers/responses.js +48 -6
  49. package/dist/providers/responses.js.map +1 -1
  50. package/dist/types.d.ts +26 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js +13 -5
  53. package/dist/types.js.map +1 -1
  54. package/package.json +5 -1
  55. package/src/auth/device.ts +137 -0
  56. package/src/auth/flows.ts +526 -0
  57. package/src/auth/index.ts +7 -0
  58. package/src/auth/oauth.ts +226 -0
  59. package/src/auth/token-source.ts +76 -0
  60. package/src/context.ts +16 -1
  61. package/src/errors.ts +3 -0
  62. package/src/presets.ts +33 -4
  63. package/src/providers/anthropic.ts +10 -1
  64. package/src/providers/factory.ts +21 -6
  65. package/src/providers/gemini.ts +7 -0
  66. package/src/providers/openai.ts +12 -0
  67. package/src/providers/responses.ts +60 -6
  68. package/src/types.ts +49 -5
@@ -0,0 +1,526 @@
1
+ // Each vendor's sign-in and renewal, built on the shared pieces in oauth.ts and
2
+ // device.ts. The client ids are the public ones each vendor's own CLI ships.
3
+ //
4
+ // ponytail: public client ids and fixed redirect ports. The ceiling is that a
5
+ // vendor can rotate a client id, add client attestation or refuse a port, and
6
+ // that vendor's sign-in breaks. Its entry below is then the only thing to fix.
7
+ import {
8
+ accountFromToken,
9
+ asRecord,
10
+ authError,
11
+ generatePKCE,
12
+ jwtClaims,
13
+ num,
14
+ postToken,
15
+ randomState,
16
+ REFRESH_SKEW_MS,
17
+ str,
18
+ toCredential,
19
+ webUrl,
20
+ type AuthHost,
21
+ type Credential,
22
+ } from "./oauth.ts";
23
+ import { pollDeviceToken, requestDeviceCode, type DeviceEndpoint } from "./device.ts";
24
+
25
+ /** What the user must do on the vendor's page to finish signing in. */
26
+ export interface SignInPrompt {
27
+ /** The approval page. The host has already been asked to open it. */
28
+ url: string;
29
+ /** Device-code flows only: the code that page asks for. */
30
+ userCode?: string;
31
+ }
32
+
33
+ /** One vendor's two operations: sign in once, then renew. */
34
+ export interface AuthFlow {
35
+ signIn(signal: AbortSignal, onPrompt: (prompt: SignInPrompt) => void): Promise<Credential>;
36
+ /** Trades the stored credential for a fresh one. Both tokens may rotate, so
37
+ * save the result before you use it. */
38
+ refresh(credential: Credential): Promise<Credential>;
39
+ }
40
+
41
+ export type AuthFlowId =
42
+ "chatgpt" | "kimi-plan" | "grok" | "github-copilot" | "meta" | "openrouter";
43
+
44
+ export function createAuthFlow(id: AuthFlowId, host: AuthHost): AuthFlow {
45
+ switch (id) {
46
+ case "chatgpt":
47
+ return chatgptFlow(host);
48
+ case "kimi-plan":
49
+ return deviceFlow(host, KIMI_DEVICE, kimiCredential, kimiRefresh(host));
50
+ case "grok":
51
+ return deviceFlow(host, grokDevice(host), grokCredential, grokRefresh(host));
52
+ case "github-copilot":
53
+ return deviceFlow(
54
+ host,
55
+ GITHUB_DEVICE,
56
+ (body) => mintCopilotCredential(host, body),
57
+ (credential) => refreshCopilot(host, credential),
58
+ );
59
+ case "meta":
60
+ return deviceFlow(
61
+ host,
62
+ META_DEVICE,
63
+ (body) => mintMetaCredential(host, body),
64
+ (credential) => refreshMeta(host, credential),
65
+ );
66
+ case "openrouter":
67
+ return openrouterFlow(host);
68
+ }
69
+ }
70
+
71
+ /** A device-code sign-in: ask for the code, show it, open the approval page,
72
+ * poll until the user approves, then turn the token body into a credential. */
73
+ function deviceFlow(
74
+ host: AuthHost,
75
+ endpoint: DeviceEndpoint,
76
+ toCredentialFrom: (body: Record<string, unknown>) => Credential | Promise<Credential>,
77
+ refresh: AuthFlow["refresh"],
78
+ ): AuthFlow {
79
+ return {
80
+ async signIn(signal, onPrompt) {
81
+ const prompt = await requestDeviceCode(host, endpoint);
82
+ onPrompt({ url: prompt.verificationUrl, userCode: prompt.userCode });
83
+ // If the host can't open it, the code stays on screen as the fallback.
84
+ void Promise.resolve(host.openUrl(prompt.verificationUrl)).catch(() => {});
85
+ return toCredentialFrom(await pollDeviceToken(host, endpoint, prompt, signal));
86
+ },
87
+ refresh,
88
+ };
89
+ }
90
+
91
+ /** Refresh-token renewal that rotates both tokens, shared by Kimi and xAI. */
92
+ function refreshWith(
93
+ host: AuthHost,
94
+ endpoint: DeviceEndpoint,
95
+ toCredentialFrom: (body: Record<string, unknown>, fallbackRefresh: string) => Credential,
96
+ ): AuthFlow["refresh"] {
97
+ return async (credential) =>
98
+ toCredentialFrom(
99
+ await postToken(
100
+ host,
101
+ endpoint.tokenUrl,
102
+ {
103
+ client_id: endpoint.clientId,
104
+ grant_type: "refresh_token",
105
+ refresh_token: credential.refreshToken,
106
+ },
107
+ { encode: endpoint.encode },
108
+ ),
109
+ credential.refreshToken,
110
+ );
111
+ }
112
+
113
+ // ── ChatGPT: PKCE through a redirect ──────────────────────────────────────
114
+
115
+ const CHATGPT = {
116
+ clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
117
+ authorizeUrl: "https://auth.openai.com/oauth/authorize",
118
+ tokenUrl: "https://auth.openai.com/oauth/token",
119
+ redirectUri: "http://localhost:1455/auth/callback",
120
+ // The `api.connectors.*` scopes unlock the subscription quota the Codex
121
+ // backend bills against.
122
+ scopes: "openid profile email offline_access api.connectors.read api.connectors.invoke",
123
+ } as const;
124
+
125
+ /** The authorize URL to open. Exported so a test can pin the exact params. */
126
+ export function chatgptAuthorizeUrl(challenge: string, state: string): string {
127
+ const params = new URLSearchParams({
128
+ response_type: "code",
129
+ client_id: CHATGPT.clientId,
130
+ redirect_uri: CHATGPT.redirectUri,
131
+ scope: CHATGPT.scopes,
132
+ code_challenge: challenge,
133
+ code_challenge_method: "S256",
134
+ state,
135
+ // The Codex CLI's own params: the simplified flow skips the consent page
136
+ // behind the subscription upsell.
137
+ codex_cli_simplified_flow: "true",
138
+ originator: "opencodex",
139
+ id_token_add_organizations: "true",
140
+ });
141
+ return `${CHATGPT.authorizeUrl}?${params.toString()}`;
142
+ }
143
+
144
+ function chatgptFlow(host: AuthHost): AuthFlow {
145
+ const tokenRequest = (params: Record<string, string>) =>
146
+ postToken(
147
+ host,
148
+ CHATGPT.tokenUrl,
149
+ { client_id: CHATGPT.clientId, ...params },
150
+ { encode: "form" },
151
+ );
152
+ return {
153
+ async signIn(signal, onPrompt) {
154
+ const { verifier, challenge } = await generatePKCE();
155
+ const state = randomState();
156
+ const authorizeUrl = chatgptAuthorizeUrl(challenge, state);
157
+ onPrompt({ url: authorizeUrl });
158
+ const code = await host.captureRedirect({
159
+ authorizeUrl,
160
+ redirectUri: CHATGPT.redirectUri,
161
+ state,
162
+ signal,
163
+ });
164
+ return chatgptCredential(
165
+ await tokenRequest({
166
+ grant_type: "authorization_code",
167
+ code,
168
+ redirect_uri: CHATGPT.redirectUri,
169
+ code_verifier: verifier,
170
+ }),
171
+ );
172
+ },
173
+ async refresh(credential) {
174
+ const body = await tokenRequest({
175
+ grant_type: "refresh_token",
176
+ refresh_token: credential.refreshToken,
177
+ });
178
+ return chatgptCredential(body, credential.refreshToken, credential.chatgptAccountId);
179
+ },
180
+ };
181
+ }
182
+
183
+ function chatgptCredential(
184
+ body: Record<string, unknown>,
185
+ fallbackRefresh?: string,
186
+ fallbackAccountId?: string,
187
+ ): Credential {
188
+ const credential = toCredential(body, fallbackRefresh);
189
+ // The id token has the richer claims. The access token covers a refresh
190
+ // response that leaves it out.
191
+ const idToken = str(body.id_token);
192
+ const account =
193
+ accountFromToken(idToken, "email") ?? accountFromToken(credential.accessToken, "email");
194
+ const chatgptAccountId =
195
+ chatgptAccountIdFromToken(idToken) ??
196
+ chatgptAccountIdFromToken(credential.accessToken) ??
197
+ fallbackAccountId;
198
+ return {
199
+ ...credential,
200
+ ...(account ? { account } : {}),
201
+ ...(chatgptAccountId ? { chatgptAccountId } : {}),
202
+ };
203
+ }
204
+
205
+ /** The `ChatGPT-Account-Id` the Codex backend requires. OpenAI puts it at
206
+ * `chatgpt_account_id`, under `https://api.openai.com/auth`, or as
207
+ * `organizations[0].id`, depending on the token. */
208
+ export function chatgptAccountIdFromToken(token?: string): string | undefined {
209
+ const claims = jwtClaims(token);
210
+ if (!claims) return undefined;
211
+ const nested = asRecord(claims["https://api.openai.com/auth"]);
212
+ const orgs = claims.organizations;
213
+ const firstOrg = Array.isArray(orgs) ? asRecord(orgs[0]) : {};
214
+ return str(claims.chatgpt_account_id) ?? str(nested.chatgpt_account_id) ?? str(firstOrg.id);
215
+ }
216
+
217
+ // ── Kimi and Grok: device code, then a plain refresh token ────────────────
218
+
219
+ const KIMI_DEVICE: DeviceEndpoint = {
220
+ clientId: "17e5f671-d194-4dfb-9706-5516cb48c098",
221
+ deviceUrl: "https://auth.kimi.ai/api/oauth/device_authorization",
222
+ tokenUrl: "https://auth.kimi.ai/api/oauth/token",
223
+ encode: "form",
224
+ };
225
+
226
+ function kimiCredential(body: Record<string, unknown>, fallbackRefresh?: string): Credential {
227
+ const credential = toCredential(body, fallbackRefresh);
228
+ const account = accountFromToken(credential.accessToken, "email", "user_id", "sub");
229
+ return account ? { ...credential, account } : credential;
230
+ }
231
+
232
+ const kimiRefresh = (host: AuthHost) => refreshWith(host, KIMI_DEVICE, kimiCredential);
233
+
234
+ function grokDevice(host: AuthHost): DeviceEndpoint {
235
+ return {
236
+ clientId: "b1a00492-073a-47ea-816f-4c329264a828",
237
+ deviceUrl: "https://auth.x.ai/oauth2/device/code",
238
+ tokenUrl: "https://auth.x.ai/oauth2/token",
239
+ scope: "openid profile email offline_access grok-cli:access api:access",
240
+ // Names the app instead of claiming to be the Grok CLI.
241
+ extra: { referrer: host.appName },
242
+ encode: "form",
243
+ };
244
+ }
245
+
246
+ /** xAI leaves `expires_in` out of some responses. An hour is what its tokens
247
+ * carry when it does send one. A guess that is too short costs one extra
248
+ * refresh, while no expiry would be a credential nothing ever renews. */
249
+ const GROK_DEFAULT_LIFETIME_SEC = 3600;
250
+
251
+ function grokCredential(body: Record<string, unknown>, fallbackRefresh?: string): Credential {
252
+ const credential = toCredential(
253
+ { ...body, expires_in: num(body.expires_in) ?? GROK_DEFAULT_LIFETIME_SEC },
254
+ fallbackRefresh,
255
+ );
256
+ const account = accountFromToken(credential.accessToken, "email", "sub");
257
+ return account ? { ...credential, account } : credential;
258
+ }
259
+
260
+ const grokRefresh = (host: AuthHost) => refreshWith(host, grokDevice(host), grokCredential);
261
+
262
+ // ── GitHub Copilot: device code, then a mint ──────────────────────────────
263
+ // GitHub's device flow ends in a long-lived GitHub token that the Copilot API
264
+ // does not accept. It is traded at `copilot_internal/v2/token` for a Copilot
265
+ // token that lasts about 25 minutes, and trading again IS the refresh. So
266
+ // `refreshToken` holds the GitHub token and `accessToken` the Copilot one.
267
+ //
268
+ // ponytail: github.com only. A GitHub Enterprise Server tenant serves these
269
+ // endpoints on its own domain. Supporting it means threading one domain through
270
+ // these constants.
271
+
272
+ const GITHUB_DEVICE: DeviceEndpoint = {
273
+ // VS Code's Copilot Chat client id. GitHub issues no self-service Copilot
274
+ // client id, so every third-party client uses this one, and the consent page
275
+ // says "GitHub Copilot Chat". Swap this constant for your own OAuth app's.
276
+ clientId: "Iv1.b507a08c87ecfe98",
277
+ deviceUrl: "https://github.com/login/device/code",
278
+ tokenUrl: "https://github.com/login/oauth/access_token",
279
+ // Enough to read the account name. Copilot itself is entitled by the account.
280
+ scope: "read:user",
281
+ encode: "form",
282
+ };
283
+
284
+ const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
285
+ const GITHUB_USER_URL = "https://api.github.com/user";
286
+ /** An individual seat's host. Other plans get their own, named by the token. */
287
+ export const COPILOT_DEFAULT_BASE_URL = "https://api.individual.githubcopilot.com";
288
+
289
+ /**
290
+ * The editor headers Copilot's gate wants on every call.
291
+ *
292
+ * `turn` matters for the bill. GitHub charges one premium request per
293
+ * user-started turn and lets the agent's own follow-ups ride free, and
294
+ * `X-Initiator` is how a client says which one this is. Leave it off and every
295
+ * tool call in a run bills as a new turn. The `github-copilot` preset sets it
296
+ * for you (`initiatorHeader`); this is for a call you make yourself.
297
+ */
298
+ export function copilotHeaders(appName: string, turn: "user" | "agent"): Record<string, string> {
299
+ return {
300
+ "Copilot-Integration-Id": "vscode-chat",
301
+ "Editor-Version": appName,
302
+ "Editor-Plugin-Version": appName,
303
+ "X-GitHub-Api-Version": "2026-06-01",
304
+ "Openai-Intent": "conversation-edits",
305
+ "Copilot-Vision-Request": "true",
306
+ "X-Initiator": turn,
307
+ };
308
+ }
309
+
310
+ async function mintCopilotToken(host: AuthHost, githubToken: string): Promise<Credential> {
311
+ const res = await (host.fetchImpl ?? fetch)(COPILOT_TOKEN_URL, {
312
+ headers: {
313
+ Accept: "application/json",
314
+ Authorization: `Bearer ${githubToken}`,
315
+ ...copilotHeaders(host.appName, "user"),
316
+ },
317
+ });
318
+ const text = await res.text().catch(() => "");
319
+ const record = asRecord(parseJson(text));
320
+ if (!res.ok) {
321
+ throw authError(
322
+ "token_refused",
323
+ `GitHub refused the Copilot token request: ${str(record.message) ?? res.status}`,
324
+ { status: res.status, body: text.slice(0, 2_000) },
325
+ );
326
+ }
327
+ const accessToken = str(record.token);
328
+ // Absolute epoch seconds here, not the `expires_in` every other vendor sends.
329
+ const expiresAtSec = num(record.expires_at);
330
+ if (!accessToken || expiresAtSec === undefined) {
331
+ throw authError("token_incomplete", "GitHub's Copilot token reply has no token or expiry.");
332
+ }
333
+ return {
334
+ accessToken,
335
+ refreshToken: githubToken,
336
+ expiresAt: expiresAtSec * 1000 - REFRESH_SKEW_MS,
337
+ baseUrl:
338
+ copilotApiHost(record.endpoints) ??
339
+ copilotHostFromToken(accessToken) ??
340
+ COPILOT_DEFAULT_BASE_URL,
341
+ };
342
+ }
343
+
344
+ async function mintCopilotCredential(
345
+ host: AuthHost,
346
+ body: Record<string, unknown>,
347
+ ): Promise<Credential> {
348
+ const githubToken = str(body.access_token);
349
+ if (!githubToken) throw authError("token_incomplete", "GitHub's reply has no access token.");
350
+ const credential = await mintCopilotToken(host, githubToken);
351
+ const account = await githubLogin(host, githubToken);
352
+ return account ? { ...credential, account } : credential;
353
+ }
354
+
355
+ async function refreshCopilot(host: AuthHost, credential: Credential): Promise<Credential> {
356
+ const fresh = await mintCopilotToken(host, credential.refreshToken);
357
+ return credential.account ? { ...fresh, account: credential.account } : fresh;
358
+ }
359
+
360
+ /** The login for display. A sign-in that worked must not fail because this
361
+ * lookup did, so any failure means no name. */
362
+ async function githubLogin(host: AuthHost, githubToken: string): Promise<string | undefined> {
363
+ try {
364
+ const res = await (host.fetchImpl ?? fetch)(GITHUB_USER_URL, {
365
+ headers: { Accept: "application/json", Authorization: `Bearer ${githubToken}` },
366
+ });
367
+ return res.ok ? str(asRecord(await res.json()).login) : undefined;
368
+ } catch {
369
+ return undefined;
370
+ }
371
+ }
372
+
373
+ /** The host a current token response names outright. */
374
+ function copilotApiHost(endpoints: unknown): string | undefined {
375
+ return apiBase(str(asRecord(endpoints).api));
376
+ }
377
+
378
+ /** The same host read off the token, for a response with no endpoint map. A
379
+ * Copilot token is a `;`-joined field list with
380
+ * `proxy-ep=proxy.<plan>.githubcopilot.com`, and the API host is that with
381
+ * `proxy.` swapped for `api.`. */
382
+ function copilotHostFromToken(token: string): string | undefined {
383
+ const proxy = /(?:^|;)proxy-ep=([^;]+)/.exec(token)?.[1];
384
+ return proxy ? apiBase(`https://${proxy.replace(/^proxy\./, "api.")}`) : undefined;
385
+ }
386
+
387
+ const apiBase = (url: string | undefined) => webUrl(url, ["https:"])?.replace(/\/$/, "");
388
+
389
+ // ── Meta Muse: device code, then a key mint ───────────────────────────────
390
+ // The device flow's identity token proves who you are and buys nothing. It is
391
+ // traded for a Model API key that lasts about a day, and trading again IS the
392
+ // refresh. The identity token itself cannot be renewed, so a 401 or 403 from
393
+ // the mint means the session is over and the user must sign in again.
394
+
395
+ const META_DEVICE: DeviceEndpoint = {
396
+ // The Muse Code CLI's client id. Meta issues no self-service one.
397
+ clientId: "1031625952748946",
398
+ deviceUrl: "https://auth.meta.com/oidc/device/authorization/",
399
+ tokenUrl: "https://auth.meta.com/oidc/device/token/",
400
+ encode: "form",
401
+ };
402
+
403
+ const META_KEY_URL = "https://api.meta.ai/muse-code/key";
404
+ /** Meta doesn't say how long a key lasts. */
405
+ const META_KEY_LIFETIME_MS = 24 * 60 * 60 * 1000;
406
+
407
+ async function mintMetaKey(host: AuthHost, identityToken: string): Promise<Credential> {
408
+ const res = await (host.fetchImpl ?? fetch)(META_KEY_URL, {
409
+ method: "POST",
410
+ headers: {
411
+ Accept: "application/json",
412
+ "Content-Type": "application/json",
413
+ Authorization: `Bearer ${identityToken}`,
414
+ "x-api-version": "1.0.0",
415
+ },
416
+ body: "{}",
417
+ });
418
+ const text = await res.text().catch(() => "");
419
+ const record = asRecord(parseJson(text));
420
+ if (!res.ok) {
421
+ throw authError(
422
+ "token_refused",
423
+ `Meta refused the key request: ${str(record.error_description) ?? str(record.detail) ?? res.status}`,
424
+ { status: res.status, body: text.slice(0, 2_000) },
425
+ );
426
+ }
427
+ const apiKey = str(record.api_key);
428
+ if (!apiKey) {
429
+ // Meta answers 200 with no key when the account exists but Muse was never
430
+ // set up on it. `action_url` points at the page to do that.
431
+ const actionUrl = webUrl(str(record.action_url), ["https:"]);
432
+ throw authError(
433
+ "setup_required",
434
+ actionUrl
435
+ ? `Muse isn't set up on this Meta account. Set it up at ${actionUrl}, then sign in again.`
436
+ : "Muse isn't set up on this Meta account. Set it up with Meta, then sign in again.",
437
+ );
438
+ }
439
+ return {
440
+ accessToken: apiKey,
441
+ refreshToken: identityToken,
442
+ expiresAt: Date.now() + META_KEY_LIFETIME_MS - REFRESH_SKEW_MS,
443
+ };
444
+ }
445
+
446
+ async function mintMetaCredential(
447
+ host: AuthHost,
448
+ body: Record<string, unknown>,
449
+ ): Promise<Credential> {
450
+ const identityToken = str(body.access_token);
451
+ if (!identityToken) throw authError("token_incomplete", "Meta's reply has no identity token.");
452
+ const credential = await mintMetaKey(host, identityToken);
453
+ // The minted key is opaque. The identity token is the one with claims.
454
+ const account = accountFromToken(identityToken, "email", "preferred_username", "sub");
455
+ return account ? { ...credential, account } : credential;
456
+ }
457
+
458
+ async function refreshMeta(host: AuthHost, credential: Credential): Promise<Credential> {
459
+ const fresh = await mintMetaKey(host, credential.refreshToken);
460
+ return credential.account ? { ...fresh, account: credential.account } : fresh;
461
+ }
462
+
463
+ // ── OpenRouter: PKCE, ending in an API key ────────────────────────────────
464
+ // The code is traded for an ordinary API key on the user's own account. It
465
+ // never expires and the user can revoke it at openrouter.ai, so there is
466
+ // nothing to refresh. There is no client id: PKCE is the whole proof.
467
+ //
468
+ // ponytail: a fixed redirect port. If something else on the machine serves
469
+ // 54546 it sees the code first. The exchange then fails and the user starts
470
+ // over, which is the right end for a code we can no longer trust.
471
+
472
+ const OPENROUTER = {
473
+ authorizeUrl: "https://openrouter.ai/auth",
474
+ keysUrl: "https://openrouter.ai/api/v1/auth/keys",
475
+ redirectUri: "http://localhost:54546/callback",
476
+ } as const;
477
+
478
+ /** A key doesn't expire, so its `expiresAt` is as far out as a number goes. */
479
+ const NEVER = Number.MAX_SAFE_INTEGER;
480
+
481
+ function openrouterFlow(host: AuthHost): AuthFlow {
482
+ return {
483
+ async signIn(signal, onPrompt) {
484
+ const { verifier, challenge } = await generatePKCE();
485
+ const authorizeUrl = `${OPENROUTER.authorizeUrl}?${new URLSearchParams({
486
+ callback_url: OPENROUTER.redirectUri,
487
+ code_challenge: challenge,
488
+ code_challenge_method: "S256",
489
+ })}`;
490
+ onPrompt({ url: authorizeUrl });
491
+ // No `state`: OpenRouter's authorize endpoint takes none.
492
+ const code = await host.captureRedirect({
493
+ authorizeUrl,
494
+ redirectUri: OPENROUTER.redirectUri,
495
+ signal,
496
+ });
497
+ const res = await (host.fetchImpl ?? fetch)(OPENROUTER.keysUrl, {
498
+ method: "POST",
499
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
500
+ body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
501
+ });
502
+ const text = await res.text().catch(() => "");
503
+ const record = asRecord(parseJson(text));
504
+ const key = str(record.key);
505
+ if (!key) {
506
+ throw authError(
507
+ "token_refused",
508
+ `OpenRouter refused the key request: ${str(record.message) ?? str(record.error) ?? res.status}`,
509
+ { status: res.status, body: text.slice(0, 2_000) },
510
+ );
511
+ }
512
+ return { accessToken: key, refreshToken: "", expiresAt: NEVER };
513
+ },
514
+ // Nothing to renew. A revoked key shows up as the call's own 401, and
515
+ // signing in again mints a new one.
516
+ refresh: (credential) => Promise.resolve(credential),
517
+ };
518
+ }
519
+
520
+ function parseJson(text: string): unknown {
521
+ try {
522
+ return JSON.parse(text);
523
+ } catch {
524
+ return null;
525
+ }
526
+ }
@@ -0,0 +1,7 @@
1
+ // `@providerkit/core/auth`: sign in to a subscription and keep the token fresh.
2
+ // Fetch only. The app supplies what a browser or a window must do (open a page,
3
+ // catch a redirect) and where tokens are stored; this package does the protocol.
4
+ export * from "./oauth.ts";
5
+ export * from "./device.ts";
6
+ export * from "./flows.ts";
7
+ export * from "./token-source.ts";