diff --git a/bun.lock b/bun.lock index b47b0eb6b..e4fab04b0 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "@corbits/code", "dependencies": { "@corbits/codex-provider": "github:corbitsdev/corbits-codex-provider", + "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "@corbits/xai-provider": "github:corbitsdev/corbits-xai-provider", "@intx/agent": "workspace:*", @@ -774,7 +775,7 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@corbits/codex-provider/@corbits/openai-responses": ["@corbits/openai-responses@github:corbitsdev/corbits-openai-responses#1d20dbb", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-openai-responses-1d20dbb", "sha512-lSJz1KkKD8mEUYiQ57dJprD9JavdJGt3OBRa42tvXLsikt1fJ6qX2mGCwaEXzkfZNc2XY5o7ZpxK5swSJX+LnA=="], + "@corbits/codex-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], "@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], diff --git a/package.json b/package.json index 5e55c6d65..52ef8f11a 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ }, "dependencies": { "@corbits/codex-provider": "github:corbitsdev/corbits-codex-provider", + "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "@corbits/xai-provider": "github:corbitsdev/corbits-xai-provider", "@intx/agent": "workspace:*", diff --git a/src/auth/callback-page.test.ts b/src/auth/callback-page.test.ts index bd479266c..6abd5ba6a 100644 --- a/src/auth/callback-page.test.ts +++ b/src/auth/callback-page.test.ts @@ -1,13 +1,19 @@ import { describe, expect, test } from "bun:test"; import { - PRODUCT_GITHUB_LABEL, - PRODUCT_GITHUB_URL, - PRODUCT_SITE_LABEL, - PRODUCT_SITE_URL, -} from "../branding.js"; -import { callbackPageHtml, humanizeIdentifier } from "./callback-page.js"; -import { authorizationDoneHtml } from "./oauth/callback-server.js"; + authorizationDoneHtml, + callbackPageHtml, + humanizeIdentifier, + type CallbackPageCopy, +} from "./callback-page.js"; + +const copy: CallbackPageCopy = { + productName: "Fixture Product", + siteUrl: "https://fixture.example", + siteLabel: "fixture.example", + githubUrl: "https://github.com/fixture", + githubLabel: "github.com/fixture", +}; describe("humanizeIdentifier", () => { test("machine identifiers lose their separators and lead with a capital", () => { @@ -24,59 +30,57 @@ describe("humanizeIdentifier", () => { describe("callbackPageHtml", () => { test("success names the server that connected", () => { - const html = callbackPageHtml({ subject: "linear" }); + const html = callbackPageHtml({ subject: "linear" }, copy); expect(html).toContain("Linear connected successfully"); expect(html).not.toContain("access_denied"); }); test("provider authorization waits for native setup before claiming connection", () => { - const html = authorizationDoneHtml("Codex"); + const html = authorizationDoneHtml("Codex", copy); expect(html).toContain("Codex authorization received"); expect(html).toContain("finish setup"); expect(html).not.toContain("connected successfully"); }); test("failure names the server and the humanized reason", () => { - const html = callbackPageHtml({ - subject: "granola", - error: "access_denied", - }); + const html = callbackPageHtml( + { + subject: "granola", + error: "access_denied", + }, + copy, + ); expect(html).toContain("Granola failed to connect"); expect(html).toContain("Access denied."); expect(html).not.toContain("access_denied"); }); test("an unnamed authorization still renders both outcomes", () => { - expect(callbackPageHtml()).toContain("Authorization complete"); - expect(callbackPageHtml({ error: "server_error" })).toContain( + expect(callbackPageHtml({}, copy)).toContain("Authorization complete"); + expect(callbackPageHtml({ error: "server_error" }, copy)).toContain( "Authorization did not complete", ); }); test("the subject is escaped rather than pasted into markup", () => { - expect(callbackPageHtml({ subject: "" })).not.toContain( - "" }, copy), + ).not.toContain("`, "", ].join(""); } + +export function authorizationDoneHtml( + providerName: string, + copy: CallbackPageCopy, +): string { + return callbackPageHtml({ subject: providerName, pendingSetup: true }, copy); +} diff --git a/src/auth/codex/callback-server.ts b/src/auth/codex/callback-server.ts index 5aec3744d..eb467b3f7 100644 --- a/src/auth/codex/callback-server.ts +++ b/src/auth/codex/callback-server.ts @@ -1,8 +1,10 @@ +import { startCallbackServer, type CallbackServer } from "@corbits/oauth-core"; + import { authorizationDoneHtml, - startCallbackServer, - type CallbackServer, -} from "../oauth/callback-server.js"; + callbackPageHtml, + type CallbackPageCopy, +} from "../callback-page.js"; import { CODEX_CALLBACK_PATH, CODEX_CALLBACK_PORT } from "./constants.js"; export type CodexCallbackServer = CallbackServer; @@ -11,13 +13,15 @@ export type CodexCallbackServer = CallbackServer; // server only accepts this exact redirect_uri for this client. export async function startCodexCallbackServer( expectedState: string, + copy: CallbackPageCopy, ): Promise { return startCallbackServer(expectedState, { port: CODEX_CALLBACK_PORT, - path: CODEX_CALLBACK_PATH, // Codex's registered redirect_uri uses localhost (not 127.0.0.1). - publicHost: "localhost", - doneHtml: authorizationDoneHtml("Codex"), - label: "Codex", + host: "localhost", + path: CODEX_CALLBACK_PATH, + doneHtml: authorizationDoneHtml("Codex", copy), + failedHtml: (reason) => + callbackPageHtml({ subject: "Codex", error: reason }, copy), }); } diff --git a/src/auth/codex/index.ts b/src/auth/codex/index.ts index 345ef487f..3c98533bf 100644 --- a/src/auth/codex/index.ts +++ b/src/auth/codex/index.ts @@ -8,14 +8,13 @@ export { CODEX_DEFAULT_MODELS, CODEX_REDIRECT_URI, } from "./constants.js"; +export type { CodexProfile, CodexTokens } from "./store.js"; export { listCodexProfiles, loadCodexProfile, removeCodexProfile, saveCodexProfile, - type CodexProfile, - type CodexTokens, -} from "./store.js"; +} from "../../config/oauth-stores.js"; export { getValidCodexToken, isCodexTokenExpired, diff --git a/src/auth/codex/login.ts b/src/auth/codex/login.ts index 2a99f6c72..669f3b689 100644 --- a/src/auth/codex/login.ts +++ b/src/auth/codex/login.ts @@ -1,28 +1,35 @@ -import { openInBrowser } from "../oauth/browser.js"; import { + openInBrowser, startOAuthLogin, type OAuthLoginHandle, type StartOAuthLoginOptions, -} from "../oauth/login.js"; +} from "@corbits/oauth-core"; + +import type { CallbackPageCopy } from "../callback-page.js"; +import { saveCodexProfile } from "../../config/oauth-stores.js"; import { CODEX_BASE_URL, CODEX_DEFAULT_MODELS } from "./constants.js"; import { startCodexCallbackServer } from "./callback-server.js"; import { buildAuthorizeUrl, exchangeCode } from "./oauth.js"; -import { saveCodexProfile, type CodexTokens } from "./store.js"; +import type { CodexTokens } from "./store.js"; export { openInBrowser }; export type CodexLoginHandle = OAuthLoginHandle; -export type StartCodexLoginOptions = StartOAuthLoginOptions; +export type StartCodexLoginOptions = StartOAuthLoginOptions & { + home?: string; + copy: CallbackPageCopy; +}; // Drive the loopback PKCE login for a Codex profile. export async function startCodexLogin( opts: StartCodexLoginOptions, ): Promise { - return startOAuthLogin(opts, { - startCallbackServer: startCodexCallbackServer, + const { home, copy, ...loginOpts } = opts; + return startOAuthLogin(loginOpts, { + startCallbackServer: (state) => startCodexCallbackServer(state, copy), buildAuthorizeUrl, exchangeCode, - saveProfile: saveCodexProfile, + saveProfile: (profile) => saveCodexProfile(profile, home), }); } diff --git a/src/auth/codex/oauth.ts b/src/auth/codex/oauth.ts index 360d020fe..5b249abaf 100644 --- a/src/auth/codex/oauth.ts +++ b/src/auth/codex/oauth.ts @@ -4,9 +4,10 @@ import { exchangeCode as exchangeSharedCode, refreshTokenRequest, type OAuthClientConfig, + type Pkce, type TokenResponse, -} from "../oauth/client.js"; -import type { Pkce } from "../oauth/pkce.js"; +} from "@corbits/oauth-core"; + import { CODEX_AUTHORIZE_EXTRA_PARAMS, CODEX_AUTHORIZE_URL, @@ -26,7 +27,6 @@ export const codexOAuthConfig: OAuthClientConfig = { scopes: CODEX_SCOPES, extraAuthorizeParams: CODEX_AUTHORIZE_EXTRA_PARAMS, tokenTimeoutMs: CODEX_TOKEN_TIMEOUT_MS, - label: "Codex", }; // Build the authorization URL the user opens to grant Codex access. @@ -62,6 +62,10 @@ export function accountIdFromIdToken( return undefined; } +// Default access-token lifetime when the server omits expires_in. Conservative +// so the refresh path engages sooner rather than trusting a stale token. +const DEFAULT_EXPIRES_IN_S = 3600; + // Convert a token response to stored tokens. `now` is injectable so callers // (and tests) control the expiry baseline; `previousRefresh` is carried forward // when a refresh response omits a new refresh_token (servers may rotate or not). @@ -70,10 +74,12 @@ export function tokensFromResponse( now: number, previousRefresh?: string, ): CodexTokens { - const base = baseTokensFromResponse(response, now, previousRefresh, "Codex"); + const base = baseTokensFromResponse(response, now, previousRefresh); const accountId = accountIdFromIdToken(response.id_token); return { - ...base, + access: base.access, + refresh: base.refresh, + expiresAt: base.expiresAt ?? now + DEFAULT_EXPIRES_IN_S * 1000, ...(accountId !== undefined ? { accountId } : {}), }; } diff --git a/src/auth/codex/pkce.ts b/src/auth/codex/pkce.ts index d87dc8db1..8387398d1 100644 --- a/src/auth/codex/pkce.ts +++ b/src/auth/codex/pkce.ts @@ -1,2 +1 @@ -// Re-export shared PKCE helpers so existing imports under auth/codex keep working. -export { generatePkce, generateState, type Pkce } from "../oauth/pkce.js"; +export { generatePkce, generateState, type Pkce } from "@corbits/oauth-core"; diff --git a/src/auth/codex/session.ts b/src/auth/codex/session.ts index b13940e58..c8c7804e3 100644 --- a/src/auth/codex/session.ts +++ b/src/auth/codex/session.ts @@ -1,11 +1,18 @@ -import { createTokenSession } from "../oauth/session.js"; -import { CODEX_REFRESH_SKEW_MS } from "./constants.js"; -import { refreshTokens } from "./oauth.js"; +import { + createTokenSession, + isTokenExpired, + OAuthProfileNotFoundError, + OAuthRefreshFailedError, + type TokenSession, +} from "@corbits/oauth-core"; + import { loadCodexProfile, updateCodexTokens, - type CodexTokens, -} from "./store.js"; +} from "../../config/oauth-stores.js"; +import { CODEX_REFRESH_SKEW_MS } from "./constants.js"; +import { refreshTokens } from "./oauth.js"; +import type { CodexTokens } from "./store.js"; // Raised when a Codex profile cannot yield a usable access token: it is gone, // or its refresh token has been revoked/expired. Carries the profile name so @@ -36,37 +43,66 @@ export interface CodexAccess { accountId?: string | undefined; } -const session = createTokenSession({ - skewMs: CODEX_REFRESH_SKEW_MS, - loadProfile: loadCodexProfile, - updateTokens: updateCodexTokens, - refreshTokens, - toAccess: (tokens) => ({ - access: tokens.access, - accountId: tokens.accountId, - }), - // The refresh response rarely re-issues an id_token, so carry the account id - // forward from the prior tokens when the refresh did not supply one. - mergeRefreshed: (refreshed, previous) => - refreshed.accountId === undefined && previous.accountId !== undefined - ? { ...refreshed, accountId: previous.accountId } - : refreshed, - missingError: (name) => - new CodexAuthError( +function wrapCodexAuthError(name: string, err: unknown): never { + if (err instanceof OAuthProfileNotFoundError) { + throw new CodexAuthError( name, "missing", `Codex profile "${name}" is not authorized. Log in again.`, - ), - refreshFailedError: (name, err) => - new CodexAuthError( + ); + } + if (err instanceof OAuthRefreshFailedError) { + const cause = err.cause; + throw new CodexAuthError( name, "refresh-failed", - `Codex profile "${name}" could not be refreshed (${err instanceof Error ? err.message : String(err)}). Log in again.`, - ), -}); + `Codex profile "${name}" could not be refreshed (${cause instanceof Error ? cause.message : String(cause)}). Log in again.`, + ); + } + throw err; +} + +const sessions = new Map>(); + +function sessionFor(home?: string): TokenSession { + const key = home ?? ""; + const existing = sessions.get(key); + if (existing !== undefined) return existing; + const created = createTokenSession({ + skewMs: CODEX_REFRESH_SKEW_MS, + loadProfile: (name) => loadCodexProfile(name, home), + updateTokens: (name, tokens) => updateCodexTokens(name, tokens, home), + refreshTokens, + toAccess: (tokens) => ({ + access: tokens.access, + accountId: tokens.accountId, + }), + // The refresh response rarely re-issues an id_token, so carry the account id + // forward from the prior tokens when the refresh did not supply one. + mergeRefreshed: (refreshed, previous) => + refreshed.accountId === undefined && previous.accountId !== undefined + ? { ...refreshed, accountId: previous.accountId } + : refreshed, + }); + sessions.set(key, created); + return created; +} -export const isCodexTokenExpired = session.isExpired; -export const getValidCodexToken = session.getValidToken; +export function isCodexTokenExpired(tokens: CodexTokens, now: number): boolean { + return isTokenExpired(tokens, now, CODEX_REFRESH_SKEW_MS); +} + +export async function getValidCodexToken( + name: string, + now?: number, + home?: string, +): Promise { + try { + return await sessionFor(home).getValidToken(name, now); + } catch (err) { + wrapCodexAuthError(name, err); + } +} export async function refreshStagedCodexTokens( tokens: CodexTokens, diff --git a/src/auth/codex/store.ts b/src/auth/codex/store.ts index 676f39896..1e6c2a4ed 100644 --- a/src/auth/codex/store.ts +++ b/src/auth/codex/store.ts @@ -1,8 +1,7 @@ -import { - createAuthStore, - type AuthProfile, - type BaseTokens, -} from "../oauth/store.js"; +import { type } from "arktype"; +import type { AuthProfile } from "@corbits/oauth-core"; + +import { createAuthStore, type BaseTokens } from "../store.js"; // On-disk store for Codex OAuth profiles. A user may hold multiple Codex // subscriptions (personal, work, ...), so credentials are keyed by a @@ -17,25 +16,21 @@ export type CodexTokens = BaseTokens & { export type CodexProfile = AuthProfile; +const CodexTokensShape = type({ + access: "string", + refresh: "string", + expiresAt: "number", + "accountId?": "string", +}); + function isCodexTokens(value: unknown): value is CodexTokens { - if (typeof value !== "object" || value === null) return false; - const t = value as Record; - return ( - typeof t.access === "string" && - typeof t.refresh === "string" && - typeof t.expiresAt === "number" && - (t.accountId === undefined || typeof t.accountId === "string") - ); + return !(CodexTokensShape(value) instanceof type.errors); } -const store = createAuthStore({ - filename: "codex-auth.json", - isTokens: isCodexTokens, -}); - -export const codexAuthPath = store.authPath; -export const listCodexProfiles = store.listProfiles; -export const loadCodexProfile = store.loadProfile; -export const saveCodexProfile = store.saveProfile; -export const updateCodexTokens = store.updateTokens; -export const removeCodexProfile = store.removeProfile; +export function createCodexAuthStore(settingsDirName: string) { + return createAuthStore({ + filename: "codex-auth.json", + settingsDirName, + isTokens: isCodexTokens, + }); +} diff --git a/src/auth/codex/usage.ts b/src/auth/codex/usage.ts index 57df0dcce..ffcd1016c 100644 --- a/src/auth/codex/usage.ts +++ b/src/auth/codex/usage.ts @@ -6,7 +6,6 @@ import { CODEX_AUTHORIZE_EXTRA_PARAMS, } from "./constants.js"; import { getValidCodexToken } from "./session.js"; -import { COMMAND_NAME } from "../../branding.js"; // Live usage/quota for a prepaid Codex plan. Since the subscription is not // billed per token, dollar cost is meaningless — what matters is how much of @@ -84,14 +83,17 @@ function parseUsage(payload: unknown): CodexUsage { }; } -export function codexAuthHeadersForToken(token: { - readonly access: string; - readonly accountId?: string | undefined; -}): Record { +export function codexAuthHeadersForToken( + token: { + readonly access: string; + readonly accountId?: string | undefined; + }, + commandName: string, +): Record { const headers: Record = { authorization: `Bearer ${token.access}`, originator: CODEX_AUTHORIZE_EXTRA_PARAMS["originator"] ?? "codex_cli_rs", - "user-agent": `${COMMAND_NAME} (codex_cli_rs/${CODEX_CLIENT_VERSION})`, + "user-agent": `${commandName} (codex_cli_rs/${CODEX_CLIENT_VERSION})`, }; if (token.accountId !== undefined) headers["chatgpt-account-id"] = token.accountId; @@ -100,16 +102,21 @@ export function codexAuthHeadersForToken(token: { export async function codexAuthHeaders( profileName: string, + commandName: string, ): Promise> { - return codexAuthHeadersForToken(await getValidCodexToken(profileName)); + return codexAuthHeadersForToken( + await getValidCodexToken(profileName), + commandName, + ); } // Fetch the live usage/quota snapshot for a Codex profile. export async function fetchCodexUsage( profileName: string, + commandName: string, ): Promise { const res = await fetch(`${CODEX_BASE_URL}${CODEX_USAGE_PATH}`, { - headers: await codexAuthHeaders(profileName), + headers: await codexAuthHeaders(profileName, commandName), }); if (!res.ok) { throw new Error(`Codex usage request failed (HTTP ${String(res.status)}).`); @@ -120,10 +127,13 @@ export async function fetchCodexUsage( // Fetch the account's available Codex model ids. Returns an empty array when the // account has no models available (e.g. while rate-limited), in which case the // caller falls back to the default list. -export async function fetchCodexModels(profileName: string): Promise { +export async function fetchCodexModels( + profileName: string, + commandName: string, +): Promise { const url = `${CODEX_BASE_URL}${CODEX_MODELS_PATH}?client_version=${encodeURIComponent(CODEX_CLIENT_VERSION)}`; const res = await fetch(url, { - headers: await codexAuthHeaders(profileName), + headers: await codexAuthHeaders(profileName, commandName), }); if (!res.ok) return []; const payload = (await res.json()) as unknown; diff --git a/src/auth/oauth-scope-check.test.ts b/src/auth/oauth-scope-check.test.ts index ca3ccf00d..87ca00c32 100644 --- a/src/auth/oauth-scope-check.test.ts +++ b/src/auth/oauth-scope-check.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { checkOAuthProviderScope } from "./oauth-scope-check.js"; +const commandName = "test-cli"; + const originalFetch = global.fetch; const codexTokens = { access: "staged-codex-token", @@ -37,7 +39,11 @@ describe("checkOAuthProviderScope", () => { status: 200, }); }); - const result = await checkOAuthProviderScope("codex", codexTokens); + const result = await checkOAuthProviderScope( + "codex", + codexTokens, + commandName, + ); expect(result.status).toBe("ok"); }); @@ -64,7 +70,7 @@ describe("checkOAuthProviderScope", () => { }); }); - const result = await checkOAuthProviderScope("codex", expired); + const result = await checkOAuthProviderScope("codex", expired, commandName); expect(result.status).toBe("ok"); expect(requests).toHaveLength(2); @@ -80,7 +86,7 @@ describe("checkOAuthProviderScope", () => { }), ); - const result = await checkOAuthProviderScope("codex", expired); + const result = await checkOAuthProviderScope("codex", expired, commandName); expect(result.status).toBe("blocked"); if (result.status === "blocked") { @@ -94,14 +100,18 @@ describe("checkOAuthProviderScope", () => { throw new Error("network down"); }); - const result = await checkOAuthProviderScope("codex", expired); + const result = await checkOAuthProviderScope("codex", expired, commandName); expect(result.status).toBe("unavailable"); }); test("codex: blocks a definitive 403 without surfacing the raw body", async () => { stubFetch(() => new Response("forbidden", { status: 403 })); - const result = await checkOAuthProviderScope("codex", codexTokens); + const result = await checkOAuthProviderScope( + "codex", + codexTokens, + commandName, + ); expect(result.status).toBe("blocked"); if (result.status === "blocked") { expect(result.message).toMatch(/reconnect/i); @@ -111,7 +121,11 @@ describe("checkOAuthProviderScope", () => { test("codex: blocks a definitive 401", async () => { stubFetch(() => new Response("nope", { status: 401 })); - const result = await checkOAuthProviderScope("codex", codexTokens); + const result = await checkOAuthProviderScope( + "codex", + codexTokens, + commandName, + ); expect(result.status).toBe("blocked"); }); @@ -119,13 +133,21 @@ describe("checkOAuthProviderScope", () => { stubFetch(() => { throw new Error("fetch failed"); }); - const result = await checkOAuthProviderScope("codex", codexTokens); + const result = await checkOAuthProviderScope( + "codex", + codexTokens, + commandName, + ); expect(result.status).toBe("unavailable"); }); test("codex: unavailable (not scope failure) on a 500", async () => { stubFetch(() => new Response("boom", { status: 500 })); - const result = await checkOAuthProviderScope("codex", codexTokens); + const result = await checkOAuthProviderScope( + "codex", + codexTokens, + commandName, + ); expect(result.status).toBe("unavailable"); }); @@ -136,7 +158,7 @@ describe("checkOAuthProviderScope", () => { }); return new Response(JSON.stringify({ data: [] }), { status: 200 }); }); - const result = await checkOAuthProviderScope("xai", xaiTokens); + const result = await checkOAuthProviderScope("xai", xaiTokens, commandName); expect(result.status).toBe("ok"); }); @@ -160,7 +182,7 @@ describe("checkOAuthProviderScope", () => { return new Response(JSON.stringify({ data: [] }), { status: 200 }); }); - const result = await checkOAuthProviderScope("xai", expired); + const result = await checkOAuthProviderScope("xai", expired, commandName); expect(result.status).toBe("ok"); expect(requests).toHaveLength(2); @@ -173,7 +195,7 @@ describe("checkOAuthProviderScope", () => { () => new Response(JSON.stringify({ error: "revoked" }), { status: 401 }), ); - const result = await checkOAuthProviderScope("xai", expired); + const result = await checkOAuthProviderScope("xai", expired, commandName); expect(result.status).toBe("blocked"); if (result.status === "blocked") { @@ -187,14 +209,14 @@ describe("checkOAuthProviderScope", () => { throw new DOMException("The operation timed out.", "TimeoutError"); }); - const result = await checkOAuthProviderScope("xai", expired); + const result = await checkOAuthProviderScope("xai", expired, commandName); expect(result.status).toBe("unavailable"); }); test("xai: blocks a definitive 403", async () => { stubFetch(() => new Response("forbidden", { status: 403 })); - const result = await checkOAuthProviderScope("xai", xaiTokens); + const result = await checkOAuthProviderScope("xai", xaiTokens, commandName); expect(result.status).toBe("blocked"); }); @@ -202,7 +224,7 @@ describe("checkOAuthProviderScope", () => { stubFetch(() => { throw new DOMException("The operation timed out.", "TimeoutError"); }); - const result = await checkOAuthProviderScope("xai", xaiTokens); + const result = await checkOAuthProviderScope("xai", xaiTokens, commandName); expect(result.status).toBe("unavailable"); }); }); diff --git a/src/auth/oauth-scope-check.ts b/src/auth/oauth-scope-check.ts index a92eecd6c..a9e9f0b6f 100644 --- a/src/auth/oauth-scope-check.ts +++ b/src/auth/oauth-scope-check.ts @@ -10,6 +10,8 @@ // Never logs or persists the token or any response body — only the HTTP // status is inspected to classify the result. +import { OAuthTokenEndpointError } from "@corbits/oauth-core"; + import { CODEX_BASE_URL, CODEX_MODELS_PATH, @@ -22,7 +24,6 @@ import { XAI_BASE_URL, XAI_TOKEN_TIMEOUT_MS } from "./xai/constants.js"; import { refreshStagedXaiTokens } from "./xai/session.js"; import type { XaiTokens } from "./xai/store.js"; import { xaiAuthHeadersForToken } from "./xai/usage.js"; -import { OAuthTokenEndpointError } from "./oauth/client.js"; export type OAuthScopeCheckKind = "codex" | "xai"; @@ -99,11 +100,13 @@ function classifyStatus( async function checkCodexScope( tokens: CodexTokens, + commandName: string, ): Promise { const providerLabel = "Codex"; try { const headers = codexAuthHeadersForToken( await refreshStagedCodexTokens(tokens), + commandName, ); const url = `${CODEX_BASE_URL}${CODEX_MODELS_PATH}?client_version=${encodeURIComponent(CODEX_CLIENT_VERSION)}`; const res = await fetch(url, { @@ -146,6 +149,9 @@ async function checkXaiScope( export async function checkOAuthProviderScope( kind: OAuthScopeCheckKind, tokens: CodexTokens | XaiTokens, + commandName: string, ): Promise { - return kind === "codex" ? checkCodexScope(tokens) : checkXaiScope(tokens); + return kind === "codex" + ? checkCodexScope(tokens, commandName) + : checkXaiScope(tokens); } diff --git a/src/auth/oauth/browser.ts b/src/auth/oauth/browser.ts deleted file mode 100644 index c1dd845a8..000000000 --- a/src/auth/oauth/browser.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { spawn } from "node:child_process"; -import { platform } from "node:os"; - -// Best-effort: open the authorization URL in the user's default browser. Never -// throws — a headless box or missing opener just means the user opens the -// surfaced link manually. Detached + unref so the opener cannot keep the -// process alive. -export function openInBrowser(url: string): void { - const command = - platform() === "darwin" - ? "open" - : platform() === "win32" - ? "cmd" - : "xdg-open"; - const args = platform() === "win32" ? ["/c", "start", "", url] : [url]; - try { - const child = spawn(command, args, { stdio: "ignore", detached: true }); - child.on("error", () => undefined); - child.unref(); - } catch { - // Opening the browser is a convenience; the copyable link is the fallback. - } -} diff --git a/src/auth/oauth/callback-server.ts b/src/auth/oauth/callback-server.ts deleted file mode 100644 index 77569054f..000000000 --- a/src/auth/oauth/callback-server.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { createServer, type Server } from "node:http"; -import { callbackPageHtml } from "../callback-page.js"; - -export interface CallbackServer { - // Resolves with the validated authorization code once the browser redirects - // back, or rejects if the server reports an error, the state mismatches, or - // the signal aborts. - waitForCode: (signal: AbortSignal) => Promise; - close: () => void; -} - -export interface CallbackServerConfig { - port: number; - path: string; - // Host used in the listen bind (always loopback). - bindHost?: string; - // Host used only when constructing the request URL for path matching. - publicHost?: string; - // HTML body returned on a successful authorization redirect. - doneHtml: string; - // Product label for the EADDRINUSE error ("Codex", "xAI", …). - label: string; -} - -// Start a fixed-port loopback server that receives an OAuth redirect. The port -// is fixed because authorization servers only accept the registered redirect_uri -// for the client, so a random port would be rejected. A bind failure here means -// the port is already in use (e.g. a concurrent login or another CLI), which is -// surfaced as a clear error rather than silently picking another port. -// -// `expectedState` is bound at construction (before the server listens) so the -// CSRF check is always armed: a redirect that arrives the instant the socket -// opens is validated, never accepted unchecked. -export async function startCallbackServer( - expectedState: string, - config: CallbackServerConfig, -): Promise { - const bindHost = config.bindHost ?? "127.0.0.1"; - const publicHost = config.publicHost ?? "127.0.0.1"; - - // Wire the promise resolver before the server listens so a redirect that - // arrives the instant the socket opens has a closure to settle against. - let resolveCode: ((code: string) => void) | undefined; - let rejectCode: ((err: Error) => void) | undefined; - let settled = false; - - const codePromise = new Promise((resolve, reject) => { - resolveCode = resolve; - rejectCode = reject; - }); - - const finish = (outcome: { code: string } | { error: Error }): void => { - if (settled) return; - settled = true; - if ("error" in outcome) rejectCode?.(outcome.error); - else resolveCode?.(outcome.code); - }; - - const server: Server = createServer((req, res) => { - const url = new URL( - req.url ?? "/", - `http://${publicHost}:${String(config.port)}`, - ); - if (url.pathname !== config.path) { - res.statusCode = 404; - res.end("Not found"); - return; - } - const code = url.searchParams.get("code"); - const error = url.searchParams.get("error"); - const state = url.searchParams.get("state"); - - // A state mismatch (or absent state) means this redirect does not belong to - // the flow we started; reject the request and the wait rather than trusting - // the code. The check is armed from construction, so it never fails open. - if (state !== expectedState) { - res.statusCode = 400; - res.end("Authorization failed: state mismatch"); - finish({ - error: new Error( - "Authorization state did not match; possible CSRF — login aborted.", - ), - }); - return; - } - - res.statusCode = error !== null || code === null ? 400 : 200; - res.setHeader("content-type", "text/html; charset=utf-8"); - res.end( - error !== null || code === null - ? `Authorization failed: ${error ?? "no code returned"}` - : config.doneHtml, - ); - - if (error !== null) - finish({ error: new Error(`Authorization failed: ${error}`) }); - else if (code === null) - finish({ error: new Error("Authorization redirect carried no code.") }); - else finish({ code }); - }); - - await new Promise((resolve, reject) => { - server.once("error", (err: NodeJS.ErrnoException) => { - if (err.code === "EADDRINUSE") { - reject( - new Error( - `Port ${String(config.port)} is already in use. Close any other ${config.label} login and try again.`, - ), - ); - } else { - reject(err); - } - }); - server.listen(config.port, bindHost, resolve); - }); - - return { - waitForCode: (signal: AbortSignal) => { - if (signal.aborted) finish({ error: new Error("aborted") }); - else - signal.addEventListener( - "abort", - () => finish({ error: new Error("aborted") }), - { - once: true, - }, - ); - return codePromise; - }, - close: () => server.close(), - }; -} - -export function authorizationDoneHtml(providerName: string): string { - return callbackPageHtml({ subject: providerName, pendingSetup: true }); -} diff --git a/src/auth/oauth/client.ts b/src/auth/oauth/client.ts deleted file mode 100644 index 2b482f24f..000000000 --- a/src/auth/oauth/client.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { type } from "arktype"; - -import type { Pkce } from "./pkce.js"; -import type { BaseTokens } from "./store.js"; - -// Provider-agnostic OAuth client config. Endpoints, client id, scopes, and -// timeouts stay provider-owned; this module owns the shared request shape. -export interface OAuthClientConfig { - clientId: string; - authorizeUrl: string; - tokenUrl: string; - redirectUri: string; - scopes: readonly string[]; - // Extra authorize-request params a provider requires (e.g. Codex simplified flow). - extraAuthorizeParams?: Record; - tokenTimeoutMs: number; - // Product label used in error messages ("Codex", "xAI", …). - label: string; -} - -// Build the authorization URL the user opens to grant access. The challenge -// binds this request to the PKCE verifier held locally; `state` is the CSRF -// nonce the redirect must echo back unchanged. -export function buildAuthorizeUrl( - config: OAuthClientConfig, - pkce: Pkce, - state: string, -): string { - const url = new URL(config.authorizeUrl); - url.searchParams.set("response_type", "code"); - url.searchParams.set("client_id", config.clientId); - url.searchParams.set("redirect_uri", config.redirectUri); - url.searchParams.set("scope", config.scopes.join(" ")); - url.searchParams.set("code_challenge", pkce.challenge); - url.searchParams.set("code_challenge_method", pkce.method); - url.searchParams.set("state", state); - if (config.extraAuthorizeParams !== undefined) { - for (const [key, value] of Object.entries(config.extraAuthorizeParams)) { - url.searchParams.set(key, value); - } - } - return url.toString(); -} - -// Validate the whole token response, not just access_token: a malformed -// expires_in (e.g. the string "soon") would otherwise survive and compute a NaN -// expiry, which never compares as expired, so the token would never refresh. -export const TokenResponseSchema = type({ - access_token: "string", - "refresh_token?": "string", - "expires_in?": "number", - "id_token?": "string", -}); -export type TokenResponse = typeof TokenResponseSchema.infer; - -export class OAuthTokenEndpointError extends Error { - readonly status: number; - readonly detail: string; - - constructor(label: string, status: number, detail: string) { - super( - `${label} token endpoint returned ${String(status)}${detail ? `: ${detail}` : ""}`, - ); - this.name = "OAuthTokenEndpointError"; - this.status = status; - this.detail = detail; - } -} - -// Default access-token lifetime when the server omits expires_in. Conservative -// so the refresh path engages sooner rather than trusting a stale token. -const DEFAULT_EXPIRES_IN_S = 3600; - -// Convert a token response to the shared base token fields. `now` is injectable -// so callers (and tests) control the expiry baseline; `previousRefresh` is -// carried forward when a refresh response omits a new refresh_token (servers may -// rotate or not). -export function baseTokensFromResponse( - response: TokenResponse, - now: number, - previousRefresh: string | undefined, - label: string, -): BaseTokens { - const expiresInMs = (response.expires_in ?? DEFAULT_EXPIRES_IN_S) * 1000; - const refresh = response.refresh_token ?? previousRefresh; - if (refresh === undefined) { - throw new Error( - `${label} token response carried no refresh_token and none was previously stored.`, - ); - } - return { - access: response.access_token, - refresh, - expiresAt: now + expiresInMs, - }; -} - -export async function postToken( - config: OAuthClientConfig, - body: URLSearchParams, -): Promise { - // Refresh runs on the send path before inference, outside the harness timers, - // so the token request must abort within the bounded timeout rather than hang - // the agent forever when the endpoint stalls. - const res = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "content-type": "application/x-www-form-urlencoded", - accept: "application/json", - }, - body: body.toString(), - signal: AbortSignal.timeout(config.tokenTimeoutMs), - }); - if (!res.ok) { - const detail = await res.text().catch(() => ""); - throw new OAuthTokenEndpointError(config.label, res.status, detail); - } - // AbortSignal.timeout throws a DOMException (name "TimeoutError") when it - // fires; its message ("The operation timed out") is what callers report. - const json = TokenResponseSchema(await res.json()); - if (json instanceof type.errors) { - throw new Error( - `${config.label} token endpoint returned an unexpected payload: ${json.summary}`, - ); - } - return json; -} - -// Exchange an authorization code for a raw token response. Providers map the -// response onto their stored token shape (account id, id_token, …). -export async function exchangeCode( - config: OAuthClientConfig, - code: string, - verifier: string, -): Promise { - const body = new URLSearchParams({ - grant_type: "authorization_code", - code, - client_id: config.clientId, - redirect_uri: config.redirectUri, - code_verifier: verifier, - }); - return postToken(config, body); -} - -// Mint a fresh access token from a refresh token. Returns the raw response; -// providers map it and carry the prior refresh token forward when omitted. -export async function refreshTokenRequest( - config: OAuthClientConfig, - refreshToken: string, -): Promise { - const body = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: config.clientId, - }); - return postToken(config, body); -} diff --git a/src/auth/oauth/index.ts b/src/auth/oauth/index.ts deleted file mode 100644 index b76323bc7..000000000 --- a/src/auth/oauth/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Shared PKCE + loopback OAuth plumbing used by provider-specific auth stacks -// (Codex, xAI, …). Provider modules own endpoints, ports, headers, and account -// metadata; this package owns the common request shape and session lifecycle. - -export { generatePkce, generateState, type Pkce } from "./pkce.js"; -export { openInBrowser } from "./browser.js"; -export { - startCallbackServer, - authorizationDoneHtml, - type CallbackServer, - type CallbackServerConfig, -} from "./callback-server.js"; -export { - createAuthStore, - type AuthProfile, - type AuthStore, - type AuthStoreOptions, - type BaseTokens, -} from "./store.js"; -export { - buildAuthorizeUrl, - baseTokensFromResponse, - exchangeCode, - postToken, - refreshTokenRequest, - TokenResponseSchema, - type OAuthClientConfig, - type TokenResponse, -} from "./client.js"; -export { - startOAuthLogin, - type OAuthLoginDeps, - type OAuthLoginHandle, - type StagedOAuthProfile, - type StartOAuthLoginOptions, -} from "./login.js"; -export { - createTokenSession, - isTokenExpired, - type TokenSession, - type TokenSessionDeps, -} from "./session.js"; diff --git a/src/auth/oauth/login.ts b/src/auth/oauth/login.ts deleted file mode 100644 index 28a0ce3fe..000000000 --- a/src/auth/oauth/login.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { openInBrowser } from "./browser.js"; -import type { CallbackServer } from "./callback-server.js"; -import { generatePkce, generateState, type Pkce } from "./pkce.js"; -import type { AuthProfile, BaseTokens } from "./store.js"; - -export interface StagedOAuthProfile { - readonly profile: AuthProfile; - readonly commit: () => Promise; -} - -export interface OAuthLoginHandle { - // The URL to authorize at — surfaced as a copyable link in the TUI and also - // handed to the browser opener. - authorizeUrl: string; - // Resolves after consent and exchange with an in-memory profile. The caller - // commits it only once provider setup has authorized durable mutation. - completed: Promise>; - // Tear down the callback server (also triggered via the abort signal). - cancel: () => void; -} - -export interface StartOAuthLoginOptions { - profile: string; - signal: AbortSignal; - // Injected for tests; defaults to the real clock. - now?: () => number; - home?: string; - // When false, the browser is not auto-opened (the caller surfaces the link). - // Defaults to true. - openBrowser?: boolean; -} - -export interface OAuthLoginDeps { - startCallbackServer: (expectedState: string) => Promise; - buildAuthorizeUrl: (pkce: Pkce, state: string) => string; - exchangeCode: ( - code: string, - verifier: string, - now: number, - ) => Promise; - saveProfile: ( - profile: { name: string; tokens: TTokens; createdAt: number }, - home?: string, - ) => Promise; -} - -// Drive the loopback PKCE login: start the callback server, build the authorize -// URL, and return a handle whose `completed` promise resolves after the browser -// round-trip and token exchange. The server is always closed, whether the flow -// succeeds, fails, or is aborted. -export async function startOAuthLogin( - opts: StartOAuthLoginOptions, - deps: OAuthLoginDeps, -): Promise> { - const now = opts.now ?? Date.now; - const pkce = generatePkce(); - const state = generateState(); - const server = await deps.startCallbackServer(state); - const authorizeUrl = deps.buildAuthorizeUrl(pkce, state); - - const completed = (async (): Promise> => { - try { - const code = await server.waitForCode(opts.signal); - const tokens = await deps.exchangeCode(code, pkce.verifier, now()); - const profile = { name: opts.profile, tokens, createdAt: now() }; - let committed: Promise | undefined; - return { - profile, - commit: () => { - if (!committed) { - const attempt = deps.saveProfile(profile, opts.home); - committed = attempt; - void attempt.catch(() => { - if (committed === attempt) committed = undefined; - }); - } - return committed; - }, - }; - } finally { - server.close(); - } - })(); - // The flow's own error handling lives with whoever awaits `completed`; attach - // a no-op catch so an abort before anyone awaits does not surface as an - // unhandled rejection. - completed.catch(() => undefined); - - if (opts.openBrowser !== false) openInBrowser(authorizeUrl); - - return { - authorizeUrl, - completed, - cancel: () => server.close(), - }; -} diff --git a/src/auth/oauth/oauth.test.ts b/src/auth/oauth/oauth.test.ts deleted file mode 100644 index 1a7e9fc86..000000000 --- a/src/auth/oauth/oauth.test.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { - baseTokensFromResponse, - postToken, - type OAuthClientConfig, -} from "./client.js"; -import { startOAuthLogin } from "./login.js"; -import { createTokenSession } from "./session.js"; -import { createAuthStore, type AuthProfile, type BaseTokens } from "./store.js"; - -const config: OAuthClientConfig = { - clientId: "client-id", - authorizeUrl: "https://auth.example.com/authorize", - tokenUrl: "https://auth.example.com/token", - redirectUri: "http://127.0.0.1:1455/callback", - scopes: ["openid"], - tokenTimeoutMs: 1_000, - label: "Codex", -}; - -const realFetch = globalThis.fetch; -afterEach(() => { - globalThis.fetch = realFetch; -}); - -function stubFetch(payload: unknown, status = 200): void { - globalThis.fetch = (async () => - new Response(JSON.stringify(payload), { - status, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch; -} - -describe("postToken response validation", () => { - test("rejects a malformed expires_in instead of letting NaN expiry through", async () => { - // Regression guard: the pre-shared Codex client only checked access_token - // was a string, so `expires_in: "soon"` survived and produced a NaN expiry - // that never read as expired. The shared client must reject the payload. - stubFetch({ - access_token: "tok", - refresh_token: "ref", - expires_in: "soon", - }); - await expect(postToken(config, new URLSearchParams())).rejects.toThrow( - /Codex token endpoint returned an unexpected payload/, - ); - }); - - test("rejects a payload with no access_token", async () => { - stubFetch({ refresh_token: "ref" }); - await expect(postToken(config, new URLSearchParams())).rejects.toThrow( - /unexpected payload/, - ); - }); - - test("accepts a minimal valid payload", async () => { - stubFetch({ access_token: "tok" }); - const response = await postToken(config, new URLSearchParams()); - expect(response.access_token).toBe("tok"); - }); - - test("reports the provider label and status on a non-2xx response", async () => { - stubFetch({ error: "server_error" }, 500); - await expect(postToken(config, new URLSearchParams())).rejects.toThrow( - /Codex token endpoint returned 500/, - ); - }); -}); - -describe("baseTokensFromResponse", () => { - test("computes expiry from expires_in and keeps the issued refresh token", () => { - const tokens = baseTokensFromResponse( - { access_token: "a", refresh_token: "r", expires_in: 60 }, - 1_000, - undefined, - "Codex", - ); - expect(tokens).toEqual({ access: "a", refresh: "r", expiresAt: 61_000 }); - }); - - test("defaults the lifetime when expires_in is omitted and carries the prior refresh forward", () => { - const tokens = baseTokensFromResponse( - { access_token: "a" }, - 0, - "prior-refresh", - "Codex", - ); - expect(tokens.refresh).toBe("prior-refresh"); - expect(tokens.expiresAt).toBe(3_600_000); - }); - - test("throws when no refresh token exists anywhere", () => { - expect(() => - baseTokensFromResponse({ access_token: "a" }, 0, undefined, "Codex"), - ).toThrow(/no refresh_token/); - }); -}); - -type TestTokens = BaseTokens & { accountId?: string }; - -const authStoreWriter = join( - import.meta.dirname, - "../../../tests/fixtures/auth-store-writer.ts", -); - -function isTestTokens(value: unknown): value is TestTokens { - if (typeof value !== "object" || value === null) return false; - const t = value as Record; - return ( - typeof t.access === "string" && - typeof t.refresh === "string" && - typeof t.expiresAt === "number" - ); -} - -describe("createAuthStore", () => { - test("serializes concurrent profile saves and token updates across processes", async () => { - const home = await mkdtemp(join(tmpdir(), "oauth-store-concurrent-")); - try { - const store = createAuthStore({ - filename: "concurrent-auth.json", - isTokens: isTestTokens, - }); - await store.saveProfile( - { - name: "existing", - tokens: { access: "old", refresh: "old-refresh", expiresAt: 1 }, - createdAt: 10, - }, - home, - ); - - const barrier = join(home, "start"); - const names = Array.from( - { length: 16 }, - (_, index) => `profile-${index}`, - ); - const processes = [ - ...names.map((name) => - Bun.spawn( - [process.execPath, authStoreWriter, home, barrier, "save", name], - { - stdout: "ignore", - stderr: "pipe", - }, - ), - ), - Bun.spawn( - [ - process.execPath, - authStoreWriter, - home, - barrier, - "update", - "new-access", - ], - { - stdout: "ignore", - stderr: "pipe", - }, - ), - ]; - - await Bun.sleep(50); - await writeFile(barrier, "go"); - const exitCodes = await Promise.all( - processes.map((process) => process.exited), - ); - const errors = await Promise.all( - processes.map((process) => new Response(process.stderr).text()), - ); - expect(exitCodes, errors.join("\n")).toEqual(processes.map(() => 0)); - - const profiles = await store.listProfiles(home); - expect(profiles.map((profile) => profile.name)).toEqual( - ["existing", ...names].sort(), - ); - expect(profiles.find((profile) => profile.name === "existing")).toEqual({ - name: "existing", - tokens: { - access: "new-access", - refresh: "refresh-new-access", - expiresAt: 2, - }, - createdAt: 10, - }); - for (const name of names) { - expect(profiles.find((profile) => profile.name === name)).toEqual({ - name, - tokens: { - access: `access-${name}`, - refresh: `refresh-${name}`, - expiresAt: 1, - }, - createdAt: 1, - }); - } - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - test("round-trips profiles under an injected home and survives corrupt files", async () => { - const home = await mkdtemp(join(tmpdir(), "oauth-store-")); - try { - const store = createAuthStore({ - filename: "test-auth.json", - isTokens: isTestTokens, - }); - expect(store.authPath(home)).toBe( - join(home, ".corbits", "test-auth.json"), - ); - expect(await store.listProfiles(home)).toEqual([]); - - const profile = { - name: "work", - tokens: { access: "a", refresh: "r", expiresAt: 1 }, - createdAt: 10, - }; - await store.saveProfile(profile, home); - expect(await store.loadProfile("work", home)).toEqual(profile); - - await store.updateTokens( - "work", - { access: "a2", refresh: "r2", expiresAt: 2 }, - home, - ); - const updated = await store.loadProfile("work", home); - expect(updated?.tokens.access).toBe("a2"); - expect(updated?.createdAt).toBe(10); - - // updateTokens is a no-op for a profile that no longer exists. - await store.updateTokens( - "gone", - { access: "x", refresh: "x", expiresAt: 0 }, - home, - ); - expect(await store.loadProfile("gone", home)).toBeUndefined(); - - expect(await store.removeProfile("work", home)).toEqual(["work"]); - expect(await store.removeProfile("work", home)).toEqual([]); - - // A corrupt file reads as empty state rather than throwing. - await writeFile(store.authPath(home), "{not json", { mode: 0o600 }); - expect(await store.listProfiles(home)).toEqual([]); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - test("releases the credential lock when a read-modify-write callback fails", async () => { - const home = await mkdtemp(join(tmpdir(), "oauth-store-error-")); - try { - const store = createAuthStore({ - filename: "test-auth.json", - isTokens: isTestTokens, - }); - const profile = { - name: "work", - tokens: { access: "a", refresh: "r", expiresAt: 1 }, - createdAt: 1, - }; - await store.saveProfile(profile, home); - - const failingStore = createAuthStore({ - filename: "test-auth.json", - isTokens: (_value: unknown): _value is TestTokens => { - throw new Error("validator failed"); - }, - }); - await expect(failingStore.saveProfile(profile, home)).rejects.toThrow( - "validator failed", - ); - - await expect( - store.updateTokens("work", profile.tokens, home), - ).resolves.toBeUndefined(); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - test("fails closed with manual recovery guidance when an orphan lock exists", async () => { - const home = await mkdtemp(join(tmpdir(), "oauth-store-orphan-")); - try { - const store = createAuthStore({ - filename: "test-auth.json", - isTokens: isTestTokens, - }); - const lockPath = `${store.authPath(home)}.lock`; - await mkdir(join(home, ".corbits"), { recursive: true }); - await writeFile(lockPath, "orphan", { mode: 0o600 }); - - await expect( - store.saveProfile( - { - name: "work", - tokens: { access: "a", refresh: "r", expiresAt: 1 }, - createdAt: 1, - }, - home, - ), - ).rejects.toThrow( - `Timed out waiting for OAuth credential lock ${lockPath}. ` + - "If no Corbits process is running, remove this lock file manually and retry.", - ); - expect(await readFile(lockPath, "utf8")).toBe("orphan"); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - test("drops invalid profile entries instead of wedging on them", async () => { - const home = await mkdtemp(join(tmpdir(), "oauth-store-")); - try { - const store = createAuthStore({ - filename: "test-auth.json", - isTokens: isTestTokens, - }); - await store.saveProfile( - { - name: "good", - tokens: { access: "a", refresh: "r", expiresAt: 1 }, - createdAt: 1, - }, - home, - ); - const raw = JSON.parse(await readFile(store.authPath(home), "utf8")) as { - profiles: Record; - }; - raw.profiles.bad = { - name: "bad", - tokens: { access: 42 }, - createdAt: "nope", - }; - await writeFile(store.authPath(home), JSON.stringify(raw), { - mode: 0o600, - }); - const names = (await store.listProfiles(home)).map((p) => p.name); - expect(names).toEqual(["good"]); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); -}); - -describe("startOAuthLogin", () => { - test("stages the exchanged profile until commit is invoked", async () => { - const saved: AuthProfile[] = []; - let closed = 0; - const tokens = { - access: "new-access", - refresh: "new-refresh", - expiresAt: 10_000, - }; - const handle = await startOAuthLogin( - { - profile: "work", - signal: new AbortController().signal, - now: () => 123, - openBrowser: false, - }, - { - startCallbackServer: async () => ({ - waitForCode: async () => "authorization-code", - close: () => { - closed += 1; - }, - }), - buildAuthorizeUrl: () => "https://auth.example.com/authorize", - exchangeCode: async () => tokens, - saveProfile: async (profile) => { - saved.push(profile); - }, - }, - ); - - const staged = await handle.completed; - expect(saved).toEqual([]); - expect(staged.profile).toEqual({ name: "work", tokens, createdAt: 123 }); - expect(closed).toBe(1); - - await Promise.all([staged.commit(), staged.commit()]); - expect(saved).toEqual([staged.profile]); - }); - - test("allows a failed profile commit to be retried", async () => { - let saveAttempts = 0; - const handle = await startOAuthLogin( - { - profile: "work", - signal: new AbortController().signal, - now: () => 123, - openBrowser: false, - }, - { - startCallbackServer: async () => ({ - waitForCode: async () => "authorization-code", - close: () => undefined, - }), - buildAuthorizeUrl: () => "https://auth.example.com/authorize", - exchangeCode: async () => ({ - access: "new", - refresh: "refresh", - expiresAt: 10_000, - }), - saveProfile: async () => { - saveAttempts += 1; - if (saveAttempts === 1) throw new Error("transient save failure"); - }, - }, - ); - - const staged = await handle.completed; - await expect(staged.commit()).rejects.toThrow("transient save failure"); - await expect(staged.commit()).resolves.toBeUndefined(); - expect(saveAttempts).toBe(2); - }); -}); - -describe("createTokenSession", () => { - function makeSession(overrides?: { - refreshTokens?: (refreshToken: string, now: number) => Promise; - mergeRefreshed?: ( - refreshed: TestTokens, - previous: TestTokens, - ) => TestTokens; - profile?: { tokens: TestTokens }; - }) { - const calls = { refresh: 0, updates: [] as TestTokens[] }; - let stored: { tokens: TestTokens } | undefined = overrides?.profile ?? { - tokens: { access: "old", refresh: "ref", expiresAt: 1_000 }, - }; - const session = createTokenSession({ - skewMs: 100, - loadProfile: async () => stored, - updateTokens: async (_name, tokens) => { - calls.updates.push(tokens); - stored = { tokens }; - }, - refreshTokens: - overrides?.refreshTokens ?? - (async () => { - calls.refresh += 1; - return { access: "new", refresh: "ref2", expiresAt: 10_000 }; - }), - toAccess: (tokens) => tokens.access, - ...(overrides?.mergeRefreshed !== undefined - ? { mergeRefreshed: overrides.mergeRefreshed } - : {}), - missingError: (name) => new Error(`missing ${name}`), - refreshFailedError: (name, cause) => - new Error(`refresh failed ${name}: ${String(cause)}`), - }); - return { session, calls }; - } - - test("returns the stored token on the fast path without refreshing", async () => { - const { session, calls } = makeSession(); - expect(await session.getValidToken("p", 500)).toBe("old"); - expect(calls.refresh).toBe(0); - }); - - test("refreshes an expired token and persists the result", async () => { - const { session, calls } = makeSession(); - expect(await session.getValidToken("p", 2_000)).toBe("new"); - expect(calls.refresh).toBe(1); - expect(calls.updates).toHaveLength(1); - }); - - test("treats a token inside the skew window as expired", async () => { - const { session, calls } = makeSession(); - // expiresAt 1000, skew 100: now=950 is within the window. - expect(await session.getValidToken("p", 950)).toBe("new"); - expect(calls.refresh).toBe(1); - }); - - test("coalesces concurrent refreshes into one request", async () => { - let release: (() => void) | undefined; - const gate = new Promise((resolve) => { - release = resolve; - }); - let refreshCount = 0; - const { session } = makeSession({ - refreshTokens: async () => { - refreshCount += 1; - await gate; - return { access: "new", refresh: "ref2", expiresAt: 10_000 }; - }, - }); - const first = session.getValidToken("p", 2_000); - const second = session.getValidToken("p", 2_000); - release?.(); - expect(await Promise.all([first, second])).toEqual(["new", "new"]); - expect(refreshCount).toBe(1); - }); - - test("a failed refresh clears the mutex so the next call can retry", async () => { - let attempts = 0; - const { session } = makeSession({ - refreshTokens: async () => { - attempts += 1; - if (attempts === 1) throw new Error("boom"); - return { access: "new", refresh: "ref2", expiresAt: 10_000 }; - }, - }); - await expect(session.getValidToken("p", 2_000)).rejects.toThrow( - /refresh failed p/, - ); - expect(await session.getValidToken("p", 2_000)).toBe("new"); - expect(attempts).toBe(2); - }); - - test("applies mergeRefreshed so provider fields survive a refresh", async () => { - const { session, calls } = makeSession({ - profile: { - tokens: { - access: "old", - refresh: "ref", - expiresAt: 1_000, - accountId: "acct", - }, - }, - mergeRefreshed: (refreshed, previous) => ({ - ...refreshed, - ...(previous.accountId !== undefined - ? { accountId: previous.accountId } - : {}), - }), - }); - await session.getValidToken("p", 2_000); - expect(calls.updates[0]?.accountId).toBe("acct"); - }); - - test("throws the provider missing error for an unknown profile", async () => { - const { session } = makeSession(); - // Simulate a store with no such profile. - const empty = createTokenSession({ - skewMs: 100, - loadProfile: async () => undefined, - updateTokens: async () => undefined, - refreshTokens: async () => ({ access: "x", refresh: "x", expiresAt: 0 }), - toAccess: (tokens) => tokens.access, - missingError: (name) => new Error(`missing ${name}`), - refreshFailedError: (name) => new Error(`refresh failed ${name}`), - }); - await expect(empty.getValidToken("nope", 0)).rejects.toThrow( - "missing nope", - ); - void session; - }); -}); diff --git a/src/auth/oauth/pkce.ts b/src/auth/oauth/pkce.ts deleted file mode 100644 index 470c75af5..000000000 --- a/src/auth/oauth/pkce.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; - -// PKCE (RFC 7636) and CSRF state generation for loopback OAuth flows. -// The verifier is a high-entropy random string; the challenge is its SHA-256 -// digest, base64url-encoded. The authorization server stores the challenge and -// later verifies the verifier presented at token exchange, which is what stops -// an intercepted authorization code from being redeemed by anyone else. - -function base64url(buffer: Buffer): string { - return buffer - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); -} - -export interface Pkce { - verifier: string; - challenge: string; - method: "S256"; -} - -export function generatePkce(): Pkce { - // 32 random bytes → 43-char base64url string, within the RFC's 43–128 range. - const verifier = base64url(randomBytes(32)); - const challenge = base64url(createHash("sha256").update(verifier).digest()); - return { verifier, challenge, method: "S256" }; -} - -// Opaque CSRF nonce echoed back on the redirect and checked for an exact match -// before the code is trusted. -export function generateState(): string { - return base64url(randomBytes(32)); -} diff --git a/src/auth/oauth/session.ts b/src/auth/oauth/session.ts deleted file mode 100644 index d17573da0..000000000 --- a/src/auth/oauth/session.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { BaseTokens } from "./store.js"; - -export function isTokenExpired( - tokens: BaseTokens, - now: number, - skewMs: number, -): boolean { - return now >= tokens.expiresAt - skewMs; -} - -export interface TokenSessionDeps { - skewMs: number; - loadProfile: ( - name: string, - home?: string, - ) => Promise<{ tokens: TTokens } | undefined>; - updateTokens: (name: string, tokens: TTokens, home?: string) => Promise; - refreshTokens: (refreshToken: string, now: number) => Promise; - // Project stored tokens into the access shape returned to callers. - toAccess: (tokens: TTokens) => TAccess; - // Optional merge when a refresh response omits provider-specific fields - // (e.g. Codex accountId rarely re-issued on refresh). - mergeRefreshed?: (refreshed: TTokens, previous: TTokens) => TTokens; - missingError: (name: string) => Error; - refreshFailedError: (name: string, cause: unknown) => Error; -} - -export interface TokenSession { - isExpired: (tokens: TTokens, now: number) => boolean; - getValidToken: ( - name: string, - now?: number, - home?: string, - ) => Promise; -} - -// Resolve a valid access token for a named profile, refreshing transparently -// when the stored token is at or near expiry. Multiple concurrent calls for -// the same profile coalesce into a single refresh; the second returns the -// result of the first. -export function createTokenSession( - deps: TokenSessionDeps, -): TokenSession { - // Per-profile mutex for token refreshes. When two callers both find the stored - // token expired and both attempt to refresh, the second observes the same - // in-flight promise instead of racing against the auth server's rotation - // policy (which would invalidate one of the refresh attempts). - const inflightRefresh = new Map>(); - - const isExpired = (tokens: TTokens, now: number): boolean => - isTokenExpired(tokens, now, deps.skewMs); - - async function doRefresh( - name: string, - now: number, - home?: string, - ): Promise { - const profile = await deps.loadProfile(name, home); - if (profile === undefined) throw deps.missingError(name); - // Re-check expiry after the I/O; another caller may have refreshed already. - if (!isExpired(profile.tokens, now)) return deps.toAccess(profile.tokens); - let refreshed: TTokens; - try { - refreshed = await deps.refreshTokens(profile.tokens.refresh, now); - } catch (err) { - throw deps.refreshFailedError(name, err); - } - const merged = - deps.mergeRefreshed !== undefined - ? deps.mergeRefreshed(refreshed, profile.tokens) - : refreshed; - await deps.updateTokens(name, merged, home); - return deps.toAccess(merged); - } - - async function getValidToken( - name: string, - now: number = Date.now(), - home?: string, - ): Promise { - // Fast path: check expiry without a refresh when we already hold a fresh token. - const existingProfile = await deps.loadProfile(name, home); - if (existingProfile === undefined) throw deps.missingError(name); - if (!isExpired(existingProfile.tokens, now)) - return deps.toAccess(existingProfile.tokens); - - // Slow path: a refresh is needed. Deduplicate via the in-flight map so that - // concurrent callers share the same refresh rather than racing. - const pending = inflightRefresh.get(name); - if (pending !== undefined) return pending; - - const refreshPromise = doRefresh(name, now, home); - inflightRefresh.set(name, refreshPromise); - // Clean up the mutex entry regardless of outcome so a subsequent call - // after a failure can retry rather than returning the cached error. - // Using .then(cleanup, cleanup) instead of .finally() avoids an - // abandoned promise chain whose pass-through rejection could become - // an unhandled rejection — callers catch the original refreshPromise. - const cleanup = (): void => { - if (inflightRefresh.get(name) === refreshPromise) { - inflightRefresh.delete(name); - } - }; - refreshPromise.then(cleanup, cleanup); - - return refreshPromise; - } - - return { isExpired, getValidToken }; -} diff --git a/src/auth/store.test.ts b/src/auth/store.test.ts new file mode 100644 index 000000000..169773d25 --- /dev/null +++ b/src/auth/store.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type } from "arktype"; + +import { createAuthStore, type BaseTokens } from "./store.js"; + +type TestTokens = BaseTokens & { accountId?: string }; + +const TestTokensShape = type({ + access: "string", + refresh: "string", + expiresAt: "number", + "accountId?": "string", +}); + +function isTestTokens(value: unknown): value is TestTokens { + return !(TestTokensShape(value) instanceof type.errors); +} + +const TEST_SETTINGS_DIR = ".test-settings"; + +const authStoreWriter = join( + import.meta.dirname, + "../../tests/fixtures/auth-store-writer.ts", +); + +describe("createAuthStore", () => { + test("serializes concurrent profile saves and token updates across processes", async () => { + const home = await mkdtemp(join(tmpdir(), "oauth-store-concurrent-")); + try { + const store = createAuthStore({ + filename: "concurrent-auth.json", + settingsDirName: TEST_SETTINGS_DIR, + isTokens: isTestTokens, + }); + await store.saveProfile( + { + name: "existing", + tokens: { access: "old", refresh: "old-refresh", expiresAt: 1 }, + createdAt: 10, + }, + home, + ); + + const barrier = join(home, "start"); + const names = Array.from( + { length: 16 }, + (_, index) => `profile-${String(index)}`, + ); + const processes = [ + ...names.map((name) => + Bun.spawn( + [process.execPath, authStoreWriter, home, barrier, "save", name], + { + stdout: "ignore", + stderr: "pipe", + }, + ), + ), + Bun.spawn( + [ + process.execPath, + authStoreWriter, + home, + barrier, + "update", + "new-access", + ], + { + stdout: "ignore", + stderr: "pipe", + }, + ), + ]; + + await Bun.sleep(50); + await writeFile(barrier, "go"); + const exitCodes = await Promise.all( + processes.map((child) => child.exited), + ); + const errors = await Promise.all( + processes.map((child) => new Response(child.stderr).text()), + ); + expect(exitCodes, errors.join("\n")).toEqual(processes.map(() => 0)); + + const profiles = await store.listProfiles(home); + expect(profiles.map((profile) => profile.name)).toEqual( + ["existing", ...names].sort(), + ); + expect(profiles.find((profile) => profile.name === "existing")).toEqual({ + name: "existing", + tokens: { + access: "new-access", + refresh: "refresh-new-access", + expiresAt: 2, + }, + createdAt: 10, + }); + for (const name of names) { + expect(profiles.find((profile) => profile.name === name)).toEqual({ + name, + tokens: { + access: `access-${name}`, + refresh: `refresh-${name}`, + expiresAt: 1, + }, + createdAt: 1, + }); + } + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("round-trips profiles under an injected home and survives corrupt files", async () => { + const home = await mkdtemp(join(tmpdir(), "oauth-store-")); + try { + const store = createAuthStore({ + filename: "test-auth.json", + settingsDirName: TEST_SETTINGS_DIR, + isTokens: isTestTokens, + }); + expect(store.authPath(home)).toBe( + join(home, TEST_SETTINGS_DIR, "test-auth.json"), + ); + expect(await store.listProfiles(home)).toEqual([]); + + const profile = { + name: "work", + tokens: { access: "a", refresh: "r", expiresAt: 1 }, + createdAt: 10, + }; + await store.saveProfile(profile, home); + expect(await store.loadProfile("work", home)).toEqual(profile); + + await store.updateTokens( + "work", + { access: "a2", refresh: "r2", expiresAt: 2 }, + home, + ); + const updated = await store.loadProfile("work", home); + expect(updated?.tokens.access).toBe("a2"); + expect(updated?.createdAt).toBe(10); + + await store.updateTokens( + "gone", + { access: "x", refresh: "x", expiresAt: 0 }, + home, + ); + expect(await store.loadProfile("gone", home)).toBeUndefined(); + + expect(await store.removeProfile("work", home)).toEqual(["work"]); + expect(await store.removeProfile("work", home)).toEqual([]); + + await writeFile(store.authPath(home), "{not json", { mode: 0o600 }); + expect(await store.listProfiles(home)).toEqual([]); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("releases the credential lock when a read-modify-write callback fails", async () => { + const home = await mkdtemp(join(tmpdir(), "oauth-store-error-")); + try { + const store = createAuthStore({ + filename: "test-auth.json", + settingsDirName: TEST_SETTINGS_DIR, + isTokens: isTestTokens, + }); + const profile = { + name: "work", + tokens: { access: "a", refresh: "r", expiresAt: 1 }, + createdAt: 1, + }; + await store.saveProfile(profile, home); + + const failingStore = createAuthStore({ + filename: "test-auth.json", + settingsDirName: TEST_SETTINGS_DIR, + isTokens: (_value: unknown): _value is TestTokens => { + throw new Error("validator failed"); + }, + }); + await expect(failingStore.saveProfile(profile, home)).rejects.toThrow( + "validator failed", + ); + + await expect( + store.updateTokens("work", profile.tokens, home), + ).resolves.toBeUndefined(); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("fails closed with manual recovery guidance when an orphan lock exists", async () => { + const home = await mkdtemp(join(tmpdir(), "oauth-store-orphan-")); + try { + const store = createAuthStore({ + filename: "test-auth.json", + settingsDirName: TEST_SETTINGS_DIR, + isTokens: isTestTokens, + }); + const lockPath = `${store.authPath(home)}.lock`; + await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true }); + await writeFile(lockPath, "orphan", { mode: 0o600 }); + + await expect( + store.saveProfile( + { + name: "work", + tokens: { access: "a", refresh: "r", expiresAt: 1 }, + createdAt: 1, + }, + home, + ), + ).rejects.toThrow( + `Timed out waiting for OAuth credential lock ${lockPath}. ` + + "If no Corbits process is running, remove this lock file manually and retry.", + ); + expect(await readFile(lockPath, "utf8")).toBe("orphan"); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("drops invalid profile entries instead of wedging on them", async () => { + const home = await mkdtemp(join(tmpdir(), "oauth-store-")); + try { + const store = createAuthStore({ + filename: "test-auth.json", + settingsDirName: TEST_SETTINGS_DIR, + isTokens: isTestTokens, + }); + await store.saveProfile( + { + name: "good", + tokens: { access: "a", refresh: "r", expiresAt: 1 }, + createdAt: 1, + }, + home, + ); + const raw = JSON.parse(await readFile(store.authPath(home), "utf8")); + const file = type({ profiles: "Record" })(raw); + expect(file instanceof type.errors).toBe(false); + if (file instanceof type.errors) return; + file.profiles.bad = { + name: "bad", + tokens: { access: 42 }, + createdAt: "nope", + }; + await writeFile(store.authPath(home), JSON.stringify(file), { + mode: 0o600, + }); + const names = (await store.listProfiles(home)).map((p) => p.name); + expect(names).toEqual(["good"]); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); +}); diff --git a/src/auth/oauth/store.ts b/src/auth/store.ts similarity index 75% rename from src/auth/oauth/store.ts rename to src/auth/store.ts index 12e01a67c..349997663 100644 --- a/src/auth/oauth/store.ts +++ b/src/auth/store.ts @@ -9,7 +9,10 @@ import { import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; -import { SETTINGS_DIR_NAME } from "../../branding.js"; +import { type } from "arktype"; +import type { AuthProfile, BaseTokens } from "@corbits/oauth-core"; + +export type { AuthProfile, BaseTokens }; // On-disk store for named OAuth profiles. A user may hold multiple subscriptions // for the same provider, so credentials are keyed by a user-chosen profile name @@ -17,19 +20,6 @@ import { SETTINGS_DIR_NAME } from "../../branding.js"; // and the directory 0o700. Writes go through a temp file + rename so a concurrent // reader never observes a torn file. -export interface BaseTokens { - access: string; - refresh: string; - expiresAt: number; -} - -export interface AuthProfile { - name: string; - tokens: TTokens; - // Epoch milliseconds the profile was first authorized; informational. - createdAt: number; -} - export interface AuthStore { authPath: (home?: string) => string; listProfiles: (home?: string) => Promise[]>; @@ -45,8 +35,9 @@ export interface AuthStore { } export interface AuthStoreOptions { - // Filename under ~/.corbits/ (e.g. "codex-auth.json"). + // Filename under the injected settings directory (e.g. "codex-auth.json"). filename: string; + settingsDirName: string; isTokens: (value: unknown) => value is TTokens; } @@ -57,58 +48,58 @@ interface AuthFile { const LOCK_RETRY_MS = 25; const LOCK_TIMEOUT_MS = 1_000; +const AuthFileShape = type({ + profiles: "Record", +}); + +const ProfileShape = type({ + name: "string", + createdAt: "number", + tokens: "unknown", +}); + +function isErrnoCode(err: unknown, code: string): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + err.code === code + ); +} + function isProfile( value: unknown, isTokens: (value: unknown) => value is TTokens, ): value is AuthProfile { - if (typeof value !== "object" || value === null) return false; - const p = value as Record; - return ( - typeof p.name === "string" && - typeof p.createdAt === "number" && - isTokens(p.tokens) - ); + const parsed = ProfileShape(value); + if (parsed instanceof type.errors) return false; + return isTokens(parsed.tokens); } export function createAuthStore( options: AuthStoreOptions, ): AuthStore { const authPath = (home: string = homedir()): string => - join(home, SETTINGS_DIR_NAME, options.filename); + join(home, options.settingsDirName, options.filename); async function readAuthFile(home: string): Promise> { let raw: string; try { raw = await readFile(authPath(home), "utf8"); } catch (err) { - if ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code?: unknown }).code === "ENOENT" - ) { - return { profiles: {} }; - } + if (isErrnoCode(err, "ENOENT")) return { profiles: {} }; throw err; } try { - const parsed = JSON.parse(raw) as unknown; - if ( - typeof parsed === "object" && - parsed !== null && - "profiles" in parsed - ) { - const profiles = (parsed as { profiles: unknown }).profiles; - if (typeof profiles === "object" && profiles !== null) { - // Drop any entry that fails validation rather than wedging the session - // on a single corrupt profile; a fresh login overwrites it. - const valid: Record> = {}; - for (const [name, entry] of Object.entries(profiles)) { - if (isProfile(entry, options.isTokens)) valid[name] = entry; - } - return { profiles: valid }; - } + const parsed = AuthFileShape(JSON.parse(raw)); + if (parsed instanceof type.errors) return { profiles: {} }; + // Drop any entry that fails validation rather than wedging the session + // on a single corrupt profile; a fresh login overwrites it. + const valid: Record> = {}; + for (const [name, entry] of Object.entries(parsed.profiles)) { + if (isProfile(entry, options.isTokens)) valid[name] = entry; } + return { profiles: valid }; } catch (err) { // A corrupt file should not be fatal; treat it as no state. // Re-throw unexpected errors (TypeError from a bug in the validator @@ -124,7 +115,7 @@ export function createAuthStore( ): Promise { const path = authPath(home); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - const tmp = `${path}.${process.pid}.tmp`; + const tmp = `${path}.${String(process.pid)}.tmp`; await writeFile(tmp, JSON.stringify(file, null, 2), { mode: 0o600 }); await rename(tmp, path); } @@ -144,12 +135,7 @@ export function createAuthStore( lock = await open(lockPath, "wx", 0o600); break; } catch (error) { - const isLocked = - typeof error === "object" && - error !== null && - "code" in error && - error.code === "EEXIST"; - if (!isLocked) throw error; + if (!isErrnoCode(error, "EEXIST")) throw error; if (Date.now() >= deadline) { throw new Error( `Timed out waiting for OAuth credential lock ${lockPath}. ` + diff --git a/src/auth/xai/callback-server.test.ts b/src/auth/xai/callback-server.test.ts index 2463e0784..46b3a3e58 100644 --- a/src/auth/xai/callback-server.test.ts +++ b/src/auth/xai/callback-server.test.ts @@ -1,13 +1,22 @@ import { describe, expect, test } from "bun:test"; +import type { CallbackPageCopy } from "../callback-page.js"; import { XAI_CALLBACK_PORT } from "./constants.js"; import { startXaiCallbackServer } from "./callback-server.js"; +const copy: CallbackPageCopy = { + productName: "Fixture Product", + siteUrl: "https://fixture.example", + siteLabel: "fixture.example", + githubUrl: "https://github.com/fixture", + githubLabel: "github.com/fixture", +}; + const base = `http://127.0.0.1:${String(XAI_CALLBACK_PORT)}/callback`; describe("xAI callback server", () => { test("accepts a matching state and returns the code", async () => { - const server = await startXaiCallbackServer("expected"); + const server = await startXaiCallbackServer("expected", copy); try { const wait = server.waitForCode(new AbortController().signal); const res = await fetch(`${base}?code=abc&state=expected`); @@ -19,7 +28,7 @@ describe("xAI callback server", () => { }); test("rejects state mismatches before accepting a code", async () => { - const server = await startXaiCallbackServer("expected"); + const server = await startXaiCallbackServer("expected", copy); try { const wait = server.waitForCode(new AbortController().signal).then( () => ({ ok: true as const }), diff --git a/src/auth/xai/callback-server.ts b/src/auth/xai/callback-server.ts index 19569f8d8..105735ca2 100644 --- a/src/auth/xai/callback-server.ts +++ b/src/auth/xai/callback-server.ts @@ -1,19 +1,24 @@ +import { startCallbackServer, type CallbackServer } from "@corbits/oauth-core"; + import { authorizationDoneHtml, - startCallbackServer, - type CallbackServer, -} from "../oauth/callback-server.js"; + callbackPageHtml, + type CallbackPageCopy, +} from "../callback-page.js"; import { XAI_CALLBACK_PATH, XAI_CALLBACK_PORT } from "./constants.js"; export type XaiCallbackServer = CallbackServer; export async function startXaiCallbackServer( expectedState: string, + copy: CallbackPageCopy, ): Promise { return startCallbackServer(expectedState, { port: XAI_CALLBACK_PORT, + host: "127.0.0.1", path: XAI_CALLBACK_PATH, - doneHtml: authorizationDoneHtml("xAI"), - label: "xAI", + doneHtml: authorizationDoneHtml("xAI", copy), + failedHtml: (reason) => + callbackPageHtml({ subject: "xAI", error: reason }, copy), }); } diff --git a/src/auth/xai/constants.ts b/src/auth/xai/constants.ts index c6432d445..d5afa5cb8 100644 --- a/src/auth/xai/constants.ts +++ b/src/auth/xai/constants.ts @@ -1,59 +1,27 @@ -// OAuth constants for xAI/Grok login. xAI exposes an OpenAI-compatible Chat -// Completions API at api.x.ai; OAuth tokens come from auth.x.ai and are used as -// the bearer credential in the standard openai-compatible adapter. - -export const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; - -export const XAI_ISSUER = "https://auth.x.ai"; -export const XAI_DISCOVERY_URL = `${XAI_ISSUER}/.well-known/openid-configuration`; -export const XAI_AUTHORIZE_URL = `${XAI_ISSUER}/oauth2/authorize`; -export const XAI_TOKEN_URL = `${XAI_ISSUER}/oauth2/token`; - -export const XAI_CALLBACK_PORT = 1456; -export const XAI_CALLBACK_PATH = "/callback"; -export const XAI_REDIRECT_URI = `http://127.0.0.1:${String(XAI_CALLBACK_PORT)}${XAI_CALLBACK_PATH}`; - -export const XAI_SCOPES = [ - "openid", - "profile", - "email", - "offline_access", - "grok-cli:access", - "api:access", -] as const; - -// grok-cli OAuth tokens are NOT accepted by api.x.ai (that endpoint expects an -// API key). They authenticate against the CLI chat proxy, which exposes the -// OpenAI-compatible /v1/chat/completions surface. -export const XAI_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; -// Grok-cli OAuth credentials only accept model ids the CLI chat proxy serves. -// Keep the catalog aligned with Grok Build / xAI listings; default stays the -// last CLI-advertised coding model until the proxy is confirmed to accept a -// newer flagship as the session default. -export const XAI_DEFAULT_MODELS = [ - "grok-4.5", - "grok-4.6", - "grok-composer-2.5-fast", -] as const; - -// The CLI chat proxy speaks the OpenAI Responses API at /v1/responses and -// authenticates the caller by client headers in addition to the bearer token. -// Values mirror the grok CLI's own /v1/responses request (captured live). -export const XAI_RESPONSES_PATH = "/responses"; +import { XAI_REDIRECT_URI } from "@corbits/xai-provider"; + +export { + XAI_DEFAULT_MODELS, + XAI_OAUTH_PROXY_BASE_URL as XAI_BASE_URL, + XAI_REDIRECT_URI, + XAI_REFRESH_SKEW_MS, +} from "@corbits/xai-provider"; + +const xaiRedirect = new URL(XAI_REDIRECT_URI); +export const XAI_CALLBACK_PORT = Number(xaiRedirect.port); +export const XAI_CALLBACK_PATH = xaiRedirect.pathname; + +// The CLI chat proxy speaks the OpenAI Responses API and authenticates the +// caller by client headers in addition to the bearer token. Values mirror the +// grok CLI's own request (captured live). Do not replace these with a product +// user-agent — the proxy and billing surfaces expect the grok-shell identity. export const XAI_CLIENT_IDENTIFIER = "grok-shell"; export const XAI_CLIENT_VERSION = "0.2.93"; export const XAI_USER_AGENT = "grok-shell/0.2.93 (macos; aarch64)"; -// Refresh xAI access tokens 5 minutes before they expire. xAI issues ~1-hour -// tokens so a 1-hour skew makes every fresh token immediately stale. -export const XAI_REFRESH_SKEW_MS = 5 * 60 * 1000; - -// Billing snapshot for prepaid plans (subscription tier + credit %). The CLI -// chat proxy mirrors the grok.com billing surface so the same OAuth token works. export const XAI_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing"; // Cap every token and billing request to the xAI proxy. The refresh runs on the // send path before the inference fetch arms its inactivity/total timers, so a -// stalled token endpoint would otherwise freeze the agent at turn 0 (the send -// promise never settles). Aborting here surfaces a refresh failure instead. +// stalled token endpoint would otherwise freeze the agent at turn 0. export const XAI_TOKEN_TIMEOUT_MS = 15_000; diff --git a/src/auth/xai/index.ts b/src/auth/xai/index.ts index 35f471a4c..acf9c704e 100644 --- a/src/auth/xai/index.ts +++ b/src/auth/xai/index.ts @@ -3,14 +3,13 @@ export { XAI_DEFAULT_MODELS, XAI_REDIRECT_URI, } from "./constants.js"; +export type { XaiProfile, XaiTokens } from "./store.js"; export { listXaiProfiles, loadXaiProfile, removeXaiProfile, saveXaiProfile, - type XaiProfile, - type XaiTokens, -} from "./store.js"; +} from "../../config/oauth-stores.js"; export { getValidXaiToken, isXaiTokenExpired, diff --git a/src/auth/xai/login.ts b/src/auth/xai/login.ts index 99ab0d23d..d6ebda5ce 100644 --- a/src/auth/xai/login.ts +++ b/src/auth/xai/login.ts @@ -1,28 +1,41 @@ import { + buildAuthorizeUrl, startOAuthLogin, type OAuthLoginHandle, type StartOAuthLoginOptions, -} from "../oauth/login.js"; -import { XAI_BASE_URL, XAI_DEFAULT_MODELS } from "./constants.js"; +} from "@corbits/oauth-core"; +import { + exchangeXaiCode, + XAI_DEFAULT_MODELS, + XAI_OAUTH_PROXY_BASE_URL, + xaiOAuthConfig, + type XaiTokens, +} from "@corbits/xai-provider"; + +import type { CallbackPageCopy } from "../callback-page.js"; +import { saveXaiProfile } from "../../config/oauth-stores.js"; import { startXaiCallbackServer } from "./callback-server.js"; -import { buildAuthorizeUrl, exchangeCode } from "./oauth.js"; -import { saveXaiProfile, type XaiTokens } from "./store.js"; export type XaiLoginHandle = OAuthLoginHandle; -export type StartXaiLoginOptions = StartOAuthLoginOptions; +export type StartXaiLoginOptions = StartOAuthLoginOptions & { + home?: string; + copy: CallbackPageCopy; +}; export async function startXaiLogin( opts: StartXaiLoginOptions, ): Promise { - return startOAuthLogin(opts, { - startCallbackServer: startXaiCallbackServer, - buildAuthorizeUrl, - exchangeCode, - saveProfile: saveXaiProfile, + const { home, copy, ...loginOpts } = opts; + return startOAuthLogin(loginOpts, { + startCallbackServer: (state) => startXaiCallbackServer(state, copy), + buildAuthorizeUrl: (pkce, state) => + buildAuthorizeUrl(xaiOAuthConfig, pkce, state), + exchangeCode: exchangeXaiCode, + saveProfile: (profile) => saveXaiProfile(profile, home), }); } export const xaiProviderSurface = { - baseURL: XAI_BASE_URL, + baseURL: XAI_OAUTH_PROXY_BASE_URL, models: [...XAI_DEFAULT_MODELS], } as const; diff --git a/src/auth/xai/oauth.test.ts b/src/auth/xai/oauth.test.ts deleted file mode 100644 index 1a1fc495f..000000000 --- a/src/auth/xai/oauth.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { - XAI_CLIENT_ID, - XAI_REDIRECT_URI, - XAI_TOKEN_URL, - XAI_TOKEN_TIMEOUT_MS, -} from "./constants.js"; -import { - buildAuthorizeUrl, - exchangeCode, - refreshTokens, - tokensFromResponse, -} from "./oauth.js"; - -const originalFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -describe("xAI OAuth", () => { - test("builds a PKCE authorize URL", () => { - const url = new URL( - buildAuthorizeUrl( - { verifier: "verifier", challenge: "challenge", method: "S256" }, - "state", - ), - ); - expect(url.origin + url.pathname).toBe( - "https://auth.x.ai/oauth2/authorize", - ); - expect(url.searchParams.get("response_type")).toBe("code"); - expect(url.searchParams.get("client_id")).toBe(XAI_CLIENT_ID); - expect(url.searchParams.get("redirect_uri")).toBe(XAI_REDIRECT_URI); - expect(url.searchParams.get("code_challenge")).toBe("challenge"); - expect(url.searchParams.get("code_challenge_method")).toBe("S256"); - expect(url.searchParams.get("state")).toBe("state"); - expect(url.searchParams.get("scope")).toContain("api:access"); - }); - - test("exchanges an authorization code with PKCE verifier", async () => { - let body = ""; - globalThis.fetch = (async ( - input: string | URL | Request, - init?: RequestInit, - ) => { - expect(String(input)).toBe(XAI_TOKEN_URL); - expect(init?.method).toBe("POST"); - body = String(init?.body ?? ""); - return new Response( - JSON.stringify({ - access_token: "access", - refresh_token: "refresh", - expires_in: 10, - id_token: "id", - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ); - }) as unknown as typeof fetch; - - await expect(exchangeCode("code", "verifier", 1000)).resolves.toEqual({ - access: "access", - refresh: "refresh", - expiresAt: 11_000, - idToken: "id", - }); - const params = new URLSearchParams(body); - expect(params.get("grant_type")).toBe("authorization_code"); - expect(params.get("code")).toBe("code"); - expect(params.get("client_id")).toBe(XAI_CLIENT_ID); - expect(params.get("redirect_uri")).toBe(XAI_REDIRECT_URI); - expect(params.get("code_verifier")).toBe("verifier"); - }); - - test("carries refresh token forward when refresh response omits it", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ access_token: "new-access", expires_in: 5 }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - )) as unknown as typeof fetch; - - await expect(refreshTokens("old-refresh", 2000)).resolves.toEqual({ - access: "new-access", - refresh: "old-refresh", - expiresAt: 7000, - }); - }); - - test("requires a refresh token on initial exchange", () => { - expect(() => tokensFromResponse({ access_token: "access" }, 0)).toThrow( - /no refresh_token/, - ); - }); -}); - -describe("xAI token request timeout", () => { - // A refresh fired on the send path (before each inference call) must not be - // able to hang forever: an unresponsive token endpoint would otherwise freeze - // the agent at turn 0 because the send promise never settles and the harness - // never arms its inference timers. Passing a timeout-backed signal is the - // regression guard; the platform owns firing that signal after the bound. - test("passes a timeout signal to the token endpoint", async () => { - let signal: AbortSignal | undefined; - globalThis.fetch = (async ( - _input: string | URL | Request, - init?: RequestInit, - ) => { - signal = init?.signal ?? undefined; - return new Response( - JSON.stringify({ access_token: "new-access", expires_in: 5 }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ); - }) as typeof fetch; - - await expect(refreshTokens("old-refresh", 2000)).resolves.toEqual({ - access: "new-access", - refresh: "old-refresh", - expiresAt: 7000, - }); - expect(signal).toBeInstanceOf(AbortSignal); - expect(signal?.aborted).toBe(false); - expect(XAI_TOKEN_TIMEOUT_MS).toBeGreaterThan(0); - }); -}); diff --git a/src/auth/xai/oauth.ts b/src/auth/xai/oauth.ts deleted file mode 100644 index b79e6019b..000000000 --- a/src/auth/xai/oauth.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - baseTokensFromResponse, - buildAuthorizeUrl as buildSharedAuthorizeUrl, - exchangeCode as exchangeSharedCode, - refreshTokenRequest, - type OAuthClientConfig, - type TokenResponse, -} from "../oauth/client.js"; -import type { Pkce } from "../oauth/pkce.js"; -import { - XAI_AUTHORIZE_URL, - XAI_CLIENT_ID, - XAI_REDIRECT_URI, - XAI_SCOPES, - XAI_TOKEN_TIMEOUT_MS, - XAI_TOKEN_URL, -} from "./constants.js"; -import type { XaiTokens } from "./store.js"; - -export const xaiOAuthConfig: OAuthClientConfig = { - clientId: XAI_CLIENT_ID, - authorizeUrl: XAI_AUTHORIZE_URL, - tokenUrl: XAI_TOKEN_URL, - redirectUri: XAI_REDIRECT_URI, - scopes: XAI_SCOPES, - tokenTimeoutMs: XAI_TOKEN_TIMEOUT_MS, - label: "xAI", -}; - -export function buildAuthorizeUrl(pkce: Pkce, state: string): string { - return buildSharedAuthorizeUrl(xaiOAuthConfig, pkce, state); -} - -export function tokensFromResponse( - response: TokenResponse, - now: number, - previousRefresh?: string, -): XaiTokens { - const base = baseTokensFromResponse(response, now, previousRefresh, "xAI"); - return { - ...base, - ...(response.id_token !== undefined ? { idToken: response.id_token } : {}), - }; -} - -export async function exchangeCode( - code: string, - verifier: string, - now: number, -): Promise { - return tokensFromResponse( - await exchangeSharedCode(xaiOAuthConfig, code, verifier), - now, - ); -} - -export async function refreshTokens( - refreshToken: string, - now: number, -): Promise { - return tokensFromResponse( - await refreshTokenRequest(xaiOAuthConfig, refreshToken), - now, - refreshToken, - ); -} diff --git a/src/auth/xai/pkce.ts b/src/auth/xai/pkce.ts deleted file mode 100644 index 3909f4257..000000000 --- a/src/auth/xai/pkce.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export shared PKCE helpers so existing imports under auth/xai keep working. -export { generatePkce, generateState, type Pkce } from "../oauth/pkce.js"; diff --git a/src/auth/xai/session.ts b/src/auth/xai/session.ts index 92e639ed4..3575c0b80 100644 --- a/src/auth/xai/session.ts +++ b/src/auth/xai/session.ts @@ -1,7 +1,17 @@ -import { createTokenSession } from "../oauth/session.js"; -import { XAI_REFRESH_SKEW_MS } from "./constants.js"; -import { refreshTokens } from "./oauth.js"; -import { loadXaiProfile, updateXaiTokens, type XaiTokens } from "./store.js"; +import { + createTokenSession, + isTokenExpired, + OAuthProfileNotFoundError, + OAuthRefreshFailedError, + type TokenSession, +} from "@corbits/oauth-core"; +import { + refreshXaiTokens, + XAI_REFRESH_SKEW_MS, + type XaiTokens, +} from "@corbits/xai-provider"; + +import { loadXaiProfile, updateXaiTokens } from "../../config/oauth-stores.js"; export class XaiAuthError extends Error { readonly profile: string; @@ -23,53 +33,64 @@ export interface XaiAccess { access: string; } -// The grok proxy wants the caller's user id in the x-grok-user-id header. The -// access token is a JWT whose `sub` claim is that id; decode it rather than -// threading a separately-stored value through the catalog. -export function xaiUserIdFromAccessToken(access: string): string | undefined { - const payload = access.split(".")[1]; - if (payload === undefined) return undefined; - try { - const decoded = JSON.parse( - Buffer.from(payload, "base64url").toString("utf8"), - ) as { - sub?: unknown; - }; - return typeof decoded.sub === "string" ? decoded.sub : undefined; - } catch { - return undefined; - } -} - -const session = createTokenSession({ - skewMs: XAI_REFRESH_SKEW_MS, - loadProfile: loadXaiProfile, - updateTokens: updateXaiTokens, - refreshTokens, - toAccess: (tokens) => ({ access: tokens.access }), - missingError: (name) => - new XaiAuthError( +function wrapXaiAuthError(name: string, err: unknown): never { + if (err instanceof OAuthProfileNotFoundError) { + throw new XaiAuthError( name, "missing", `xAI profile "${name}" is not authorized. Log in again.`, - ), - refreshFailedError: (name, err) => - new XaiAuthError( + ); + } + if (err instanceof OAuthRefreshFailedError) { + const cause = err.cause; + throw new XaiAuthError( name, "refresh-failed", - `xAI profile "${name}" could not be refreshed (${err instanceof Error ? err.message : String(err)}). Log in again.`, - ), -}); + `xAI profile "${name}" could not be refreshed (${cause instanceof Error ? cause.message : String(cause)}). Log in again.`, + ); + } + throw err; +} -export const isXaiTokenExpired = session.isExpired; -export const getValidXaiToken = session.getValidToken; +const sessions = new Map>(); + +function sessionFor(home?: string): TokenSession { + const key = home ?? ""; + const existing = sessions.get(key); + if (existing !== undefined) return existing; + const created = createTokenSession({ + skewMs: XAI_REFRESH_SKEW_MS, + loadProfile: (name) => loadXaiProfile(name, home), + updateTokens: (name, tokens) => updateXaiTokens(name, tokens, home), + refreshTokens: refreshXaiTokens, + toAccess: (tokens) => ({ access: tokens.access }), + }); + sessions.set(key, created); + return created; +} + +export function isXaiTokenExpired(tokens: XaiTokens, now: number): boolean { + return isTokenExpired(tokens, now, XAI_REFRESH_SKEW_MS); +} + +export async function getValidXaiToken( + name: string, + now?: number, + home?: string, +): Promise { + try { + return await sessionFor(home).getValidToken(name, now); + } catch (err) { + wrapXaiAuthError(name, err); + } +} export async function refreshStagedXaiTokens( tokens: XaiTokens, now: number = Date.now(), ): Promise { if (!isXaiTokenExpired(tokens, now)) return tokens; - const refreshed = await refreshTokens(tokens.refresh, now); + const refreshed = await refreshXaiTokens(tokens.refresh, now); Object.assign(tokens, refreshed); return tokens; } diff --git a/src/auth/xai/store.ts b/src/auth/xai/store.ts index 02c8be4dc..33772694e 100644 --- a/src/auth/xai/store.ts +++ b/src/auth/xai/store.ts @@ -1,34 +1,28 @@ -import { - createAuthStore, - type AuthProfile, - type BaseTokens, -} from "../oauth/store.js"; +import type { AuthProfile } from "@corbits/oauth-core"; +import type { XaiTokens } from "@corbits/xai-provider"; +import { type } from "arktype"; -export type XaiTokens = BaseTokens & { - idToken?: string; -}; +import { createAuthStore } from "../store.js"; + +export type { XaiTokens }; export type XaiProfile = AuthProfile; +const XaiTokensShape = type({ + access: "string", + refresh: "string", + expiresAt: "number", + "idToken?": "string", +}); + function isXaiTokens(value: unknown): value is XaiTokens { - if (typeof value !== "object" || value === null) return false; - const t = value as Record; - return ( - typeof t.access === "string" && - typeof t.refresh === "string" && - typeof t.expiresAt === "number" && - (t.idToken === undefined || typeof t.idToken === "string") - ); + return !(XaiTokensShape(value) instanceof type.errors); } -const store = createAuthStore({ - filename: "xai-auth.json", - isTokens: isXaiTokens, -}); - -export const xaiAuthPath = store.authPath; -export const listXaiProfiles = store.listProfiles; -export const loadXaiProfile = store.loadProfile; -export const saveXaiProfile = store.saveProfile; -export const updateXaiTokens = store.updateTokens; -export const removeXaiProfile = store.removeProfile; +export function createXaiAuthStore(settingsDirName: string) { + return createAuthStore({ + filename: "xai-auth.json", + settingsDirName, + isTokens: isXaiTokens, + }); +} diff --git a/src/auth/xai/usage.ts b/src/auth/xai/usage.ts index b109ad6f4..d46932bb4 100644 --- a/src/auth/xai/usage.ts +++ b/src/auth/xai/usage.ts @@ -1,3 +1,5 @@ +import { xaiUserIdFromAccessToken } from "@corbits/xai-provider"; + import { XAI_BILLING_URL, XAI_CLIENT_IDENTIFIER, @@ -5,7 +7,7 @@ import { XAI_TOKEN_TIMEOUT_MS, XAI_USER_AGENT, } from "./constants.js"; -import { getValidXaiToken, xaiUserIdFromAccessToken } from "./session.js"; +import { getValidXaiToken } from "./session.js"; // Live usage/quota for a Grok prepaid plan. Fetched from the CLI chat proxy // (which accepts our OAuth token) and mirrors the shape returned by diff --git a/src/branding.ts b/src/branding.ts index 66510e8f3..e9050ce38 100644 --- a/src/branding.ts +++ b/src/branding.ts @@ -40,3 +40,13 @@ export const SHELL_PWD_MARKER = `__${ENV_PREFIX}SHELL_PWD_END__`; // XML-ish tag wrapping the injected system prompt in the Codex Responses bridge. export const ENVIRONMENT_TAG_NAME = `${COMMAND_NAME}_environment`; + +// Copy the OAuth callback page renders. Auth takes this as an argument; it does +// not import branding. +export const productCallbackCopy = { + productName: PRODUCT_NAME, + siteUrl: PRODUCT_SITE_URL, + siteLabel: PRODUCT_SITE_LABEL, + githubUrl: PRODUCT_GITHUB_URL, + githubLabel: PRODUCT_GITHUB_LABEL, +}; diff --git a/src/config/index.ts b/src/config/index.ts index c39dcc7c4..02ff2ad0c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; +import { xaiUserIdFromAccessToken } from "@corbits/xai-provider"; import type { InferenceSource } from "@intx/types/runtime"; import { generateSessionId, @@ -20,8 +21,9 @@ import { defaultPricingCachePath, type PricingFetcherOptions, } from "../cost/pricing-fetcher.js"; -import { listCodexProfiles, type CodexProfile } from "../auth/codex/store.js"; -import { listXaiProfiles, type XaiProfile } from "../auth/xai/store.js"; +import type { CodexProfile } from "../auth/codex/store.js"; +import type { XaiProfile } from "../auth/xai/store.js"; +import { listCodexProfiles, listXaiProfiles } from "./oauth-stores.js"; import { codexProfilesToCatalogEntries, codexProvidersAsSettings, @@ -56,7 +58,6 @@ import { } from "../provider/openai-responses.js"; import { OPENCODE_SESSION_ID_OPTION } from "../provider/opencode-session.js"; import { OPENCODE_GO_MESSAGES_PROVIDER } from "../provider/opencode-go-anthropic-adapter.js"; -import { xaiUserIdFromAccessToken } from "../auth/xai/session.js"; import { OPENCODE_GO_BASE_URL, OPENCODE_GO_PROVIDER_ID, diff --git a/src/config/oauth-stores.ts b/src/config/oauth-stores.ts new file mode 100644 index 000000000..3d70a57a1 --- /dev/null +++ b/src/config/oauth-stores.ts @@ -0,0 +1,20 @@ +import { SETTINGS_DIR_NAME } from "../branding.js"; +import { createCodexAuthStore } from "../auth/codex/store.js"; +import { createXaiAuthStore } from "../auth/xai/store.js"; + +const xaiAuthStore = createXaiAuthStore(SETTINGS_DIR_NAME); +const codexAuthStore = createCodexAuthStore(SETTINGS_DIR_NAME); + +export const xaiAuthPath = xaiAuthStore.authPath; +export const listXaiProfiles = xaiAuthStore.listProfiles; +export const loadXaiProfile = xaiAuthStore.loadProfile; +export const saveXaiProfile = xaiAuthStore.saveProfile; +export const updateXaiTokens = xaiAuthStore.updateTokens; +export const removeXaiProfile = xaiAuthStore.removeProfile; + +export const codexAuthPath = codexAuthStore.authPath; +export const listCodexProfiles = codexAuthStore.listProfiles; +export const loadCodexProfile = codexAuthStore.loadProfile; +export const saveCodexProfile = codexAuthStore.saveProfile; +export const updateCodexTokens = codexAuthStore.updateTokens; +export const removeCodexProfile = codexAuthStore.removeProfile; diff --git a/src/mcp/callback-server.ts b/src/mcp/callback-server.ts index 8a6f4a3e6..e5ec71114 100644 --- a/src/mcp/callback-server.ts +++ b/src/mcp/callback-server.ts @@ -1,6 +1,7 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { callbackPageHtml } from "../auth/callback-page.js"; +import { productCallbackCopy } from "../branding.js"; export interface CallbackServer { // The redirect_uri to register with the authorization server. @@ -73,10 +74,13 @@ export async function startCallbackServer( res.statusCode = failure === undefined ? 200 : 400; res.setHeader("content-type", "text/html; charset=utf-8"); res.end( - callbackPageHtml({ - ...(serverName !== undefined ? { subject: serverName } : {}), - ...(failure !== undefined ? { error: failure } : {}), - }), + callbackPageHtml( + { + ...(serverName !== undefined ? { subject: serverName } : {}), + ...(failure !== undefined ? { error: failure } : {}), + }, + productCallbackCopy, + ), ); if (error !== null) deliver({ error: new Error(`Authorization failed: ${error}`) }); diff --git a/src/tui/provider-setup-submit.test.ts b/src/tui/provider-setup-submit.test.ts index 31dc45ad0..9e989484f 100644 --- a/src/tui/provider-setup-submit.test.ts +++ b/src/tui/provider-setup-submit.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { OAuthScopeCheckResult } from "../auth/oauth-scope-check.js"; +import { COMMAND_NAME } from "../branding.js"; import { withMockedModule } from "../../tests/helpers/mock-module.js"; // The oauth branch probes real provider scope over the network; stub the @@ -515,7 +516,9 @@ describe("buildProviderSubmitHandler", () => { ); expect(commits).toBe(1); - expect(scopeCheckCalls).toEqual([["codex", stagedCodexTokens]]); + expect(scopeCheckCalls).toEqual([ + ["codex", stagedCodexTokens, COMMAND_NAME], + ]); expect(await loadLocalSettings(localPath)).toEqual({ provider: "codex/work", model: "gpt-5", diff --git a/src/tui/provider/oauth.ts b/src/tui/provider/oauth.ts index 5d792a9f9..ff1c6c9c8 100644 --- a/src/tui/provider/oauth.ts +++ b/src/tui/provider/oauth.ts @@ -86,10 +86,10 @@ export const defaultProfileLister = async ( kind: OAuthKind, ): Promise => { if (kind === "codex") { - const { listCodexProfiles } = await import("../../auth/codex/store.js"); + const { listCodexProfiles } = await import("../../config/oauth-stores.js"); return (await listCodexProfiles()).map((p) => p.name); } - const { listXaiProfiles } = await import("../../auth/xai/store.js"); + const { listXaiProfiles } = await import("../../config/oauth-stores.js"); return (await listXaiProfiles()).map((p) => p.name); }; @@ -128,12 +128,13 @@ export const defaultLoginStarter = async ({ readonly profile: string; readonly signal: AbortSignal; }) => { + const { productCallbackCopy } = await import("../../branding.js"); if (kind === "codex") { const { startCodexLogin } = await import("../../auth/codex/login.js"); - return startCodexLogin({ profile, signal }); + return startCodexLogin({ profile, signal, copy: productCallbackCopy }); } const { startXaiLogin } = await import("../../auth/xai/login.js"); - return startXaiLogin({ profile, signal }); + return startXaiLogin({ profile, signal, copy: productCallbackCopy }); }; /** diff --git a/src/tui/provider/submit.ts b/src/tui/provider/submit.ts index f9831b8ff..4d10fcdb0 100644 --- a/src/tui/provider/submit.ts +++ b/src/tui/provider/submit.ts @@ -10,6 +10,7 @@ import { saveLocalSettings, type Settings, } from "../../config/settings.js"; +import { COMMAND_NAME } from "../../branding.js"; import { OAuthProviderScopeError, checkOAuthProviderScope, @@ -82,6 +83,7 @@ export function buildProviderSubmitHandler( const scopeCheck = await checkOAuthProviderScope( oauth.kind, oauth.tokens, + COMMAND_NAME, ); if (isBlockingOAuthScopeCheckResult(scopeCheck)) { throw new OAuthProviderScopeError(scopeCheck.message); diff --git a/src/tui/provider/types.ts b/src/tui/provider/types.ts index 8b3586650..057b57453 100644 --- a/src/tui/provider/types.ts +++ b/src/tui/provider/types.ts @@ -14,7 +14,7 @@ import type { import type { FirstClassOAuthProvider } from "../../../packages/first-class-providers/src/index.js"; import type { CodexTokens } from "../../auth/codex/store.js"; -import type { AuthProfile } from "../../auth/oauth/store.js"; +import type { AuthProfile } from "../../auth/store.js"; import type { XaiTokens } from "../../auth/xai/store.js"; import type { discoverOllamaModels as discoverOllamaModelsRequest, diff --git a/src/tui/runner/mcp.ts b/src/tui/runner/mcp.ts index 056cbf4f6..bd07dec51 100644 --- a/src/tui/runner/mcp.ts +++ b/src/tui/runner/mcp.ts @@ -4,6 +4,7 @@ * enable/disable, remove). */ +import { openInBrowser } from "@corbits/oauth-core"; import { getLogger } from "@intx/log"; import { resolveMcpServers } from "../../config/index.js"; import { @@ -25,7 +26,6 @@ import { createExaMCPServerConfig, EXA_MCP_SERVER_NAME, } from "../../mcp/exa.js"; -import { openInBrowser } from "../../auth/oauth/browser.js"; import { mergeMcpSurfaceEntries, isBuiltinRow } from "../mcp-list.js"; import { nextMcpCatalog } from "../mcp-catalog.js"; import type { MCPConnectCallbacks } from "../../agent/tools.js"; diff --git a/tests/fixtures/auth-store-writer.ts b/tests/fixtures/auth-store-writer.ts index 756cbd9f0..e811c9e27 100644 --- a/tests/fixtures/auth-store-writer.ts +++ b/tests/fixtures/auth-store-writer.ts @@ -1,20 +1,18 @@ import { readFile } from "node:fs/promises"; +import { type } from "arktype"; -import { - createAuthStore, - type BaseTokens, -} from "../../src/auth/oauth/store.js"; +import { createAuthStore, type BaseTokens } from "../../src/auth/store.js"; type TestTokens = BaseTokens & { accountId?: string }; +const TestTokensShape = type({ + access: "string", + refresh: "string", + expiresAt: "number", +}); + function isTestTokens(value: unknown): value is TestTokens { - if (typeof value !== "object" || value === null) return false; - const tokens = value as Record; - return ( - typeof tokens.access === "string" && - typeof tokens.refresh === "string" && - typeof tokens.expiresAt === "number" - ); + return !(TestTokensShape(value) instanceof type.errors); } async function waitForBarrier(path: string): Promise { @@ -48,6 +46,7 @@ if ( const store = createAuthStore({ filename: "concurrent-auth.json", + settingsDirName: ".test-settings", isTokens: isTestTokens, }); diff --git a/tests/unit/codex-auth.test.ts b/tests/unit/codex-auth.test.ts index ce61e8479..b93bf88cf 100644 --- a/tests/unit/codex-auth.test.ts +++ b/tests/unit/codex-auth.test.ts @@ -9,14 +9,14 @@ import { buildAuthorizeUrl, tokensFromResponse, } from "../../src/auth/codex/oauth.js"; +import type { CodexProfile } from "../../src/auth/codex/store.js"; import { listCodexProfiles, loadCodexProfile, removeCodexProfile, saveCodexProfile, updateCodexTokens, - type CodexProfile, -} from "../../src/auth/codex/store.js"; +} from "../../src/config/oauth-stores.js"; import { CODEX_CLIENT_ID, CODEX_REDIRECT_URI, diff --git a/tests/unit/codex-callback-server.test.ts b/tests/unit/codex-callback-server.test.ts index fde93a179..ef6650d08 100644 --- a/tests/unit/codex-callback-server.test.ts +++ b/tests/unit/codex-callback-server.test.ts @@ -1,4 +1,5 @@ import { test, expect, describe, afterEach } from "bun:test"; +import { productCallbackCopy } from "../../src/branding.js"; import { startCodexCallbackServer } from "../../src/auth/codex/callback-server.js"; import { CODEX_CALLBACK_PORT, @@ -34,7 +35,10 @@ function settle( describe("startCodexCallbackServer", () => { test("resolves with the code when state matches", async () => { - const server = await startCodexCallbackServer("good-state"); + const server = await startCodexCallbackServer( + "good-state", + productCallbackCopy, + ); active = server; const result = settle(server, new AbortController().signal); await fetch(`${base}?code=the-code&state=good-state`).catch( @@ -45,7 +49,10 @@ describe("startCodexCallbackServer", () => { }); test("rejects when the state does not match (CSRF guard)", async () => { - const server = await startCodexCallbackServer("expected-state"); + const server = await startCodexCallbackServer( + "expected-state", + productCallbackCopy, + ); active = server; const result = settle(server, new AbortController().signal); await fetch(`${base}?code=the-code&state=attacker-state`).catch( @@ -57,7 +64,10 @@ describe("startCodexCallbackServer", () => { }); test("rejects when the redirect carries no state at all", async () => { - const server = await startCodexCallbackServer("expected-state"); + const server = await startCodexCallbackServer( + "expected-state", + productCallbackCopy, + ); active = server; const result = settle(server, new AbortController().signal); await fetch(`${base}?code=the-code`).catch(() => undefined); @@ -67,7 +77,7 @@ describe("startCodexCallbackServer", () => { }); test("rejects when the authorization server returns an error", async () => { - const server = await startCodexCallbackServer("s"); + const server = await startCodexCallbackServer("s", productCallbackCopy); active = server; const result = settle(server, new AbortController().signal); await fetch(`${base}?error=access_denied&state=s`).catch(() => undefined); @@ -77,7 +87,7 @@ describe("startCodexCallbackServer", () => { }); test("aborts the wait when the signal fires", async () => { - const server = await startCodexCallbackServer("s"); + const server = await startCodexCallbackServer("s", productCallbackCopy); active = server; const controller = new AbortController(); const result = settle(server, controller.signal); diff --git a/tests/unit/codex-session.test.ts b/tests/unit/codex-session.test.ts index 8ff65cfe7..6aa9f55ad 100644 --- a/tests/unit/codex-session.test.ts +++ b/tests/unit/codex-session.test.ts @@ -10,7 +10,7 @@ import { import { loadCodexProfile, saveCodexProfile, -} from "../../src/auth/codex/store.js"; +} from "../../src/config/oauth-stores.js"; describe("isCodexTokenExpired", () => { test("not expired well before expiry", () => { diff --git a/tests/unit/xai-session.test.ts b/tests/unit/xai-session.test.ts new file mode 100644 index 000000000..a434c93b6 --- /dev/null +++ b/tests/unit/xai-session.test.ts @@ -0,0 +1,141 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + isXaiTokenExpired, + getValidXaiToken, + XaiAuthError, +} from "../../src/auth/xai/session.js"; +import { + loadXaiProfile, + saveXaiProfile, +} from "../../src/config/oauth-stores.js"; + +describe("isXaiTokenExpired", () => { + test("not expired well before expiry", () => { + expect( + isXaiTokenExpired( + { access: "a", refresh: "r", expiresAt: 1_000_000 }, + 500_000, + ), + ).toBe(false); + }); + + test("expired within the refresh skew window", () => { + // 4 minutes before expiry is inside the 5-minute skew, so treated as expired. + expect( + isXaiTokenExpired( + { access: "a", refresh: "r", expiresAt: 1_000_000 }, + 1_000_000 - 4 * 60_000, + ), + ).toBe(true); + }); + + test("expired after expiry", () => { + expect( + isXaiTokenExpired( + { access: "a", refresh: "r", expiresAt: 1_000_000 }, + 2_000_000, + ), + ).toBe(true); + }); +}); + +describe("getValidXaiToken", () => { + const realFetch = globalThis.fetch; + beforeEach(() => { + globalThis.fetch = realFetch; + }); + afterEach(() => { + globalThis.fetch = realFetch; + }); + + async function withHome(fn: (home: string) => Promise): Promise { + const home = await mkdtemp(join(tmpdir(), "xai-session-")); + try { + return await fn(home); + } finally { + await rm(home, { recursive: true, force: true }); + } + } + + test("returns the stored token when still valid (no network)", async () => { + await withHome(async (home) => { + globalThis.fetch = (() => { + throw new Error("should not be called"); + }) as unknown as typeof fetch; + await saveXaiProfile( + { + name: "p", + createdAt: 0, + tokens: { access: "live", refresh: "r", expiresAt: 10_000_000 }, + }, + home, + ); + expect((await getValidXaiToken("p", 1_000, home)).access).toBe("live"); + }); + }); + + test("refreshes and persists when the token is expired", async () => { + await withHome(async (home) => { + await saveXaiProfile( + { + name: "p", + createdAt: 0, + tokens: { access: "old", refresh: "old-r", expiresAt: 1_000 }, + }, + home, + ); + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + access_token: "fresh", + refresh_token: "new-r", + expires_in: 3600, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + )) as unknown as typeof fetch; + const token = await getValidXaiToken("p", 5_000, home); + expect(token.access).toBe("fresh"); + const stored = await loadXaiProfile("p", home); + expect(stored?.tokens.access).toBe("fresh"); + expect(stored?.tokens.refresh).toBe("new-r"); + expect(stored?.createdAt).toBe(0); + }); + }); + + test("throws XaiAuthError(missing) for an unknown profile", async () => { + await withHome(async (home) => { + const err = await getValidXaiToken("ghost", 0, home).catch( + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(XaiAuthError); + expect((err as XaiAuthError).reason).toBe("missing"); + expect((err as XaiAuthError).profile).toBe("ghost"); + }); + }); + + test("throws XaiAuthError(refresh-failed) when refresh is rejected", async () => { + await withHome(async (home) => { + await saveXaiProfile( + { + name: "p", + createdAt: 0, + tokens: { access: "old", refresh: "bad", expiresAt: 1_000 }, + }, + home, + ); + globalThis.fetch = (async () => + new Response("revoked", { status: 400 })) as unknown as typeof fetch; + const err = await getValidXaiToken("p", 5_000, home).catch( + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(XaiAuthError); + expect((err as XaiAuthError).reason).toBe("refresh-failed"); + }); + }); +});