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
+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)",