feat(ai): adapt OAuth flows to OAuthAuth (phase 4)

anthropic, openai-codex, and github-copilot flow modules gain OAuthAuth
exports (login/refresh/toAuth) wired to the prompt()/notify() login
callbacks, making the lazyOAuth attachments on the provider factories
functional. Copilot's modifyModels baseUrl rewriting becomes toAuth()
returning ModelAuth.baseUrl derived from the token proxy endpoint.

Callback-server flows race a manual_code prompt and abort it through
AuthPrompt.signal once the flow settles; OAuthAuth has no
usesCallbackServer flag. The old OAuthProviderInterface exports stay
unchanged until the coding-agent migration.
This commit is contained in:
Mario Zechner
2026-06-10 20:41:29 +02:00
parent fec0c3d12f
commit 4d5c015820
5 changed files with 293 additions and 2 deletions
+2 -2
View File
@@ -807,8 +807,8 @@ Check items off as they land. Keep this list current; it is the working state fo
### Phase 4 — OAuth adaptation
- [ ] Adapt `utils/oauth/anthropic.ts`, `openai-codex.ts`, `github-copilot.ts` to `OAuthAuth` (`login`/`refresh`/`toAuth`) + `prompt()/notify()`; `modifyModels` baseUrl rewriting becomes `toAuth().baseUrl`.
- [ ] Remove `usesCallbackServer`; callback-server flows race a `manual_code` prompt instead.
- [x] Adapt `utils/oauth/anthropic.ts`, `openai-codex.ts`, `github-copilot.ts` to `OAuthAuth` (`login`/`refresh`/`toAuth`) + `prompt()/notify()`; `modifyModels` baseUrl rewriting becomes `toAuth().baseUrl`. New exports (`anthropicOAuth`, `openaiCodexOAuth`, `githubCopilotOAuth`) sit next to the old `OAuthProviderInterface` objects, which survive until Phase 7.
- [x] No `usesCallbackServer` on `OAuthAuth`: callback-server flows race a `manual_code` prompt (aborted via `AuthPrompt.signal` once the flow settles). The old interface keeps its flag until it dies with compat.
### Phase 5 — packaging
+37
View File
@@ -6,6 +6,7 @@
*/
import type { Server } from "node:http";
import type { OAuthAuth } from "../../auth/types.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts";
@@ -378,6 +379,42 @@ export async function refreshAnthropicToken(refreshToken: string): Promise<OAuth
};
}
export const anthropicOAuth: OAuthAuth = {
name: "Anthropic (Claude Pro/Max)",
async login(callbacks) {
// The manual_code prompt races the local callback server; abort it once
// the flow settles so the UI can dismiss the pending input.
const manualAbort = new AbortController();
try {
const credentials = await loginAnthropic({
onAuth: (info) => callbacks.notify({ type: "auth_url", url: info.url, instructions: info.instructions }),
onProgress: (message) => callbacks.notify({ type: "progress", message }),
onPrompt: (prompt) =>
callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }),
onManualCodeInput: () =>
callbacks.prompt({
type: "manual_code",
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
signal: manualAbort.signal,
}),
});
return { ...credentials, type: "oauth" };
} finally {
manualAbort.abort();
}
},
async refresh(credential) {
return { ...(await refreshAnthropicToken(credential.refresh)), type: "oauth" };
},
async toAuth(credential) {
return { apiKey: credential.access };
},
};
export const anthropicOAuthProvider: OAuthProviderInterface = {
id: "anthropic",
name: "Anthropic (Claude Pro/Max)",
@@ -2,6 +2,7 @@
* GitHub Copilot OAuth flow
*/
import type { OAuthAuth, OAuthCredential } from "../../auth/types.ts";
import { getModels } from "../../models.ts";
import type { Api, Model } from "../../types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
@@ -330,6 +331,42 @@ export async function loginGitHubCopilot(options: {
return credentials;
}
function copilotEnterpriseDomain(credential: OAuthCredential): string | undefined {
const enterpriseUrl = credential.enterpriseUrl;
if (typeof enterpriseUrl !== "string" || !enterpriseUrl) return undefined;
return normalizeDomain(enterpriseUrl) ?? undefined;
}
export const githubCopilotOAuth: OAuthAuth = {
name: "GitHub Copilot",
async login(callbacks) {
const credentials = await loginGitHubCopilot({
onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }),
onPrompt: (prompt) =>
callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }),
onProgress: (message) => callbacks.notify({ type: "progress", message }),
signal: callbacks.signal,
});
return { ...credentials, type: "oauth" };
},
async refresh(credential) {
return {
...(await refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential))),
type: "oauth",
};
},
/** Per-credential baseUrl from the token's proxy endpoint replaces the old `modifyModels` rewriting. */
async toAuth(credential) {
return {
apiKey: credential.access,
baseUrl: getGitHubCopilotBaseUrl(credential.access, copilotEnterpriseDomain(credential)),
};
},
};
export const githubCopilotOAuthProvider: OAuthProviderInterface = {
id: "github-copilot",
name: "GitHub Copilot",
@@ -17,6 +17,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
});
}
import type { OAuthAuth } from "../../auth/types.ts";
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
import { generatePKCE } from "./pkce.ts";
@@ -560,6 +561,62 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
return credentialsFromToken(await refreshAccessToken(refreshToken));
}
export const openaiCodexOAuth: OAuthAuth = {
name: "OpenAI (ChatGPT Plus/Pro)",
async login(callbacks) {
const method = await callbacks.prompt({
type: "select",
message: "Select OpenAI Codex login method:",
options: [
{ id: OPENAI_CODEX_BROWSER_LOGIN_METHOD, label: "Browser login (default)" },
{ id: OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, label: "Device code login (headless)" },
],
});
if (method === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) {
const credentials = await loginOpenAICodexDeviceCode({
onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }),
signal: callbacks.signal,
});
return { ...credentials, type: "oauth" };
}
if (method !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) {
throw new Error(`Unknown OpenAI Codex login method: ${method}`);
}
// The manual_code prompt races the local callback server; abort it once
// the flow settles so the UI can dismiss the pending input.
const manualAbort = new AbortController();
try {
const credentials = await loginOpenAICodex({
onAuth: (info) => callbacks.notify({ type: "auth_url", url: info.url, instructions: info.instructions }),
onProgress: (message) => callbacks.notify({ type: "progress", message }),
onPrompt: (prompt) =>
callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }),
onManualCodeInput: () =>
callbacks.prompt({
type: "manual_code",
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
placeholder: REDIRECT_URI,
signal: manualAbort.signal,
}),
});
return { ...credentials, type: "oauth" };
} finally {
manualAbort.abort();
}
},
async refresh(credential) {
return { ...(await refreshOpenAICodexToken(credential.refresh)), type: "oauth" };
},
async toAuth(credential) {
return { apiKey: credential.access };
},
};
export const openaiCodexOAuthProvider: OAuthProviderInterface = {
id: "openai-codex",
name: "ChatGPT Plus/Pro (Codex Subscription)",
+160
View File
@@ -0,0 +1,160 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts";
import { createModels } from "../src/models.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
import { githubCopilotProvider } from "../src/providers/github-copilot.ts";
import { anthropicOAuth } from "../src/utils/oauth/anthropic.ts";
import { githubCopilotOAuth } from "../src/utils/oauth/github-copilot.ts";
import { openaiCodexOAuth } from "../src/utils/oauth/openai-codex.ts";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
describe.sequential("OAuthAuth adapters", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("anthropic toAuth derives the api key from the access token", async () => {
const auth = await anthropicOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 });
expect(auth).toEqual({ apiKey: "token" });
});
it("openai-codex toAuth derives the api key from the access token", async () => {
const auth = await openaiCodexOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 });
expect(auth).toEqual({ apiKey: "token" });
});
it("github-copilot toAuth derives baseUrl from the token proxy endpoint", async () => {
const access = "tid=abc;exp=123;proxy-ep=proxy.enterprise.example;rest";
const auth = await githubCopilotOAuth.toAuth({ type: "oauth", access, refresh: "r", expires: 0 });
expect(auth).toEqual({ apiKey: access, baseUrl: "https://api.enterprise.example" });
});
it("github-copilot toAuth falls back to the enterprise domain, then the individual endpoint", async () => {
const enterprise = await githubCopilotOAuth.toAuth({
type: "oauth",
access: "no-proxy-ep",
refresh: "r",
expires: 0,
enterpriseUrl: "https://company.ghe.com",
});
expect(enterprise.baseUrl).toBe("https://copilot-api.company.ghe.com");
const individual = await githubCopilotOAuth.toAuth({
type: "oauth",
access: "no-proxy-ep",
refresh: "r",
expires: 0,
});
expect(individual.baseUrl).toBe("https://api.individual.githubcopilot.com");
});
it("anthropic refresh exchanges the refresh token and returns a typed credential", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
jsonResponse({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 }),
),
);
const refreshed = await anthropicOAuth.refresh({ type: "oauth", access: "old", refresh: "old-r", expires: 0 });
expect(refreshed.type).toBe("oauth");
expect(refreshed.access).toBe("new-access");
expect(refreshed.refresh).toBe("new-refresh");
expect(refreshed.expires).toBeGreaterThan(Date.now());
});
it("github-copilot refresh preserves the enterprise domain", async () => {
const fetchedUrls: string[] = [];
const fetchMock = vi.fn(async (input: unknown) => {
fetchedUrls.push(typeof input === "string" ? input : String(input));
return jsonResponse({ token: "new-token", expires_at: 9999999999 });
});
vi.stubGlobal("fetch", fetchMock);
const refreshed = await githubCopilotOAuth.refresh({
type: "oauth",
access: "old",
refresh: "gh-token",
expires: 0,
enterpriseUrl: "company.ghe.com",
});
expect(refreshed.access).toBe("new-token");
expect(refreshed.enterpriseUrl).toBe("company.ghe.com");
expect(fetchedUrls[0]).toContain("api.company.ghe.com");
});
it("anthropic login resolves through the manual_code prompt and aborts it after settling", async () => {
const fetchMock = vi.fn(async (input: unknown) => {
const url = typeof input === "string" ? input : String(input);
if (url.includes("/oauth/token")) {
return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const events: AuthEvent[] = [];
const prompts: AuthPrompt[] = [];
let manualSignal: AbortSignal | undefined;
const credential = await anthropicOAuth.login({
notify: (event) => events.push(event),
prompt: async (prompt) => {
prompts.push(prompt);
if (prompt.type === "manual_code") {
manualSignal = prompt.signal;
return "the-code";
}
throw new Error(`Unexpected prompt: ${prompt.type}`);
},
});
expect(credential.type).toBe("oauth");
expect(credential.access).toBe("access");
expect(events.some((e) => e.type === "auth_url")).toBe(true);
expect(prompts.some((p) => p.type === "manual_code")).toBe(true);
// the prompt's signal is aborted once login settles, so UIs can dismiss it
expect(manualSignal?.aborted).toBe(true);
});
});
describe("OAuth through Models.getAuth (lazy load chain)", () => {
it("resolves stored anthropic oauth credentials via the lazy flow import", async () => {
const credentials = new InMemoryCredentialStore();
await credentials.modify("anthropic", async () => ({
type: "oauth",
access: "oauth-access-token",
refresh: "r",
expires: Date.now() + 60_000,
}));
const models = createModels({ credentials });
models.setProvider(anthropicProvider());
const model = (await models.getModels("anthropic"))[0];
const result = await models.getAuth(model);
expect(result?.auth.apiKey).toBe("oauth-access-token");
expect(result?.source).toBe("OAuth");
});
it("resolves stored github-copilot oauth credentials including per-credential baseUrl", async () => {
const access = "tid=abc;exp=123;proxy-ep=proxy.business.githubcopilot.com;rest";
const credentials = new InMemoryCredentialStore();
await credentials.modify("github-copilot", async () => ({
type: "oauth",
access,
refresh: "r",
expires: Date.now() + 60_000,
}));
const models = createModels({ credentials });
models.setProvider(githubCopilotProvider());
const model = (await models.getModels("github-copilot"))[0];
const result = await models.getAuth(model);
expect(result?.auth.apiKey).toBe(access);
expect(result?.auth.baseUrl).toBe("https://api.business.githubcopilot.com");
});
});