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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user