// Provider auth contract helpers define reusable tests for provider auth implementations. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearRuntimeAuthProfileStoreSnapshots, type AuthProfileStore } from "../agent-runtime.js"; import { createNonExitingRuntime } from "../runtime.js"; import type { WizardMultiSelectParams, WizardPrompter, WizardProgress, WizardSelectParams, } from "../setup.js"; import { registerProviders, requireProvider } from "./contracts-testkit.js"; type LoginOpenAICodexOAuth = (params: unknown) => Promise<{ access: string; refresh: string; expires: number; email?: string; } | null>; type EnsureAuthProfileStore = typeof import("openclaw/plugin-sdk/provider-auth").ensureAuthProfileStore; type ListProfilesForProvider = typeof import("openclaw/plugin-sdk/provider-auth").listProfilesForProvider; const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn()); const listProfilesForProviderMock = vi.hoisted(() => vi.fn()); export type ProviderAuthContractPluginLoader = () => Promise<{ default: Parameters[0]; }>; type OpenAICodexProviderAuthContractOptions = { expectedCodexDefaultModel: string; loginOpenAICodexOAuthMock: ReturnType>; }; function buildPrompter(): WizardPrompter { const progress: WizardProgress = { update() {}, stop() {}, }; return { intro: async () => {}, outro: async () => {}, note: async () => {}, select: async (params: WizardSelectParams) => { const option = params.options[0]; if (!option) { throw new Error("missing select option"); } return option.value; }, multiselect: async (params: WizardMultiSelectParams) => params.initialValues ?? [], text: async () => "", confirm: async () => false, progress: () => progress, }; } function buildAuthContext() { return { config: {}, prompter: buildPrompter(), runtime: createNonExitingRuntime(), isRemote: false, openUrl: async () => {}, oauth: { createVpsAwareHandlers: vi.fn(), }, }; } function createJwt(payload: Record): string { const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); return `${header}.${body}.signature`; } function buildOpenAICodexOAuthResult(params: { profileId: string; access: string; refresh: string; expires: number; email?: string; defaultModel: string; }) { return { profiles: [ { profileId: params.profileId, credential: { type: "oauth" as const, provider: "openai", access: params.access, refresh: params.refresh, expires: params.expires, ...(params.email ? { email: params.email } : {}), }, }, ], configPatch: { agents: { defaults: { models: { [params.defaultModel]: {}, }, }, }, }, defaultModel: params.defaultModel, notes: undefined, }; } function installSharedAuthProfileStoreHooks(state: { authStore: AuthProfileStore }) { beforeEach(() => { vi.doMock("openclaw/plugin-sdk/provider-auth", async () => { const actual = await vi.importActual( "openclaw/plugin-sdk/provider-auth", ); return { ...actual, ensureAuthProfileStore: ensureAuthProfileStoreMock, listProfilesForProvider: listProfilesForProviderMock, }; }); state.authStore = { version: 1, profiles: {} }; ensureAuthProfileStoreMock.mockReset(); ensureAuthProfileStoreMock.mockImplementation(() => state.authStore); listProfilesForProviderMock.mockReset(); listProfilesForProviderMock.mockImplementation((store, providerId) => Object.entries(store.profiles) .filter(([, credential]) => credential?.provider === providerId) .map(([profileId]) => profileId), ); }); afterEach(() => { ensureAuthProfileStoreMock.mockReset(); listProfilesForProviderMock.mockReset(); clearRuntimeAuthProfileStoreSnapshots(); }); } export function describeOpenAICodexProviderAuthContract( load: ProviderAuthContractPluginLoader, options: OpenAICodexProviderAuthContractOptions, ) { const state = { authStore: { version: 1, profiles: {} } as AuthProfileStore, }; const { expectedCodexDefaultModel, loginOpenAICodexOAuthMock } = options; describe("openai provider ChatGPT auth contract", () => { installSharedAuthProfileStoreHooks(state); async function expectStableFallbackProfile(params: { access: string; profileId: string }) { const { default: openAIPlugin } = await load(); const provider = requireProvider(await registerProviders(openAIPlugin), "openai"); loginOpenAICodexOAuthMock.mockResolvedValueOnce({ refresh: "refresh-token", access: params.access, expires: 1_700_000_000_000, }); const result = await provider.auth[0]?.run(buildAuthContext() as never); expect(result).toEqual( buildOpenAICodexOAuthResult({ profileId: params.profileId, access: params.access, refresh: "refresh-token", expires: 1_700_000_000_000, defaultModel: expectedCodexDefaultModel, }), ); } async function getProvider() { const { default: openAIPlugin } = await load(); return requireProvider(await registerProviders(openAIPlugin), "openai"); } it("keeps OAuth auth results provider-owned", async () => { const provider = await getProvider(); loginOpenAICodexOAuthMock.mockResolvedValueOnce({ email: "user@example.com", refresh: "refresh-token", access: "access-token", expires: 1_700_000_000_000, }); const result = await provider.auth[0]?.run(buildAuthContext() as never); expect(result).toEqual( buildOpenAICodexOAuthResult({ profileId: "openai:user@example.com", access: "access-token", refresh: "refresh-token", expires: 1_700_000_000_000, email: "user@example.com", defaultModel: expectedCodexDefaultModel, }), ); }); it("backfills OAuth email from the JWT profile claim", async () => { const provider = await getProvider(); const access = createJwt({ "https://api.openai.com/profile": { email: "jwt-user@example.com", }, }); loginOpenAICodexOAuthMock.mockResolvedValueOnce({ refresh: "refresh-token", access, expires: 1_700_000_000_000, }); const result = await provider.auth[0]?.run(buildAuthContext() as never); expect(result).toEqual( buildOpenAICodexOAuthResult({ profileId: "openai:jwt-user@example.com", access, refresh: "refresh-token", expires: 1_700_000_000_000, email: "jwt-user@example.com", defaultModel: expectedCodexDefaultModel, }), ); }); it("uses a stable fallback id when JWT email is missing", async () => { const access = createJwt({ "https://api.openai.com/auth": { chatgpt_account_user_id: "user-123__acct-456", }, }); const expectedStableId = Buffer.from("user-123__acct-456", "utf8").toString("base64url"); await expectStableFallbackProfile({ access, profileId: `openai:id-${expectedStableId}`, }); }); it("uses iss and sub to build a stable fallback id when auth claims are missing", async () => { const access = createJwt({ iss: "https://accounts.openai.com", sub: "user-abc", }); const expectedStableId = Buffer.from("https://accounts.openai.com|user-abc").toString( "base64url", ); await expectStableFallbackProfile({ access, profileId: `openai:id-${expectedStableId}`, }); }); it("uses sub alone to build a stable fallback id when iss is missing", async () => { const access = createJwt({ sub: "user-abc", }); const expectedStableId = Buffer.from("user-abc").toString("base64url"); await expectStableFallbackProfile({ access, profileId: `openai:id-${expectedStableId}`, }); }); it("falls back to the default profile when JWT parsing yields no identity", async () => { const provider = await getProvider(); loginOpenAICodexOAuthMock.mockResolvedValueOnce({ refresh: "refresh-token", access: "not-a-jwt-token", expires: 1_700_000_000_000, }); const result = await provider.auth[0]?.run(buildAuthContext() as never); expect(result).toEqual( buildOpenAICodexOAuthResult({ profileId: "openai:default", access: "not-a-jwt-token", refresh: "refresh-token", expires: 1_700_000_000_000, defaultModel: expectedCodexDefaultModel, }), ); }); it("surfaces OAuth failures instead of silently succeeding with no profiles", async () => { const provider = await getProvider(); loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed")); await expect(provider.auth[0]?.run(buildAuthContext() as never)).rejects.toThrow( "oauth failed", ); }); }); } export function describeGithubCopilotProviderAuthContract( load: ProviderAuthContractPluginLoader, defaultModel: string, ) { const state = { authStore: { version: 1, profiles: {} } as AuthProfileStore, }; describe("github-copilot provider auth contract", () => { installSharedAuthProfileStoreHooks(state); async function getProvider() { const { default: githubCopilotPlugin } = await load(); return requireProvider(await registerProviders(githubCopilotPlugin), "github-copilot"); } async function runDeviceAuthWithFakeTimers( run: () => T | Promise, openUrl: (url: string) => Promise, ): Promise { vi.useFakeTimers(); try { const deviceCodeShown = new Promise((resolve) => { vi.mocked(openUrl).mockImplementation(async () => resolve()); }); const pending = Promise.resolve(run()); const openedBeforeCompletion = await Promise.race([ deviceCodeShown.then(() => true), pending.then(() => false), ]); expect(openedBeforeCompletion).toBe(true); // Browser handoff follows the profile, device-code, and prompt work. await vi.advanceTimersByTimeAsync(1_000); return await pending; } finally { vi.useRealTimers(); } } function buildCopilotSetupResponse(target: string): Response | undefined { if (target === "https://api.github.com/copilot_internal/user") { return new Response( JSON.stringify({ endpoints: { api: "https://api.individual.githubcopilot.com" } }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } if (target === "https://api.individual.githubcopilot.com/models") { return new Response( JSON.stringify({ data: [ { id: defaultModel.replace("github-copilot/", ""), name: "Contract starter model", model_picker_enabled: true, model_picker_category: "versatile", policy: { state: "enabled" }, capabilities: { type: "chat", limits: { max_context_window_tokens: 200_000, max_output_tokens: 64_000, }, supports: { streaming: true, tool_calls: true }, }, }, ], }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } return undefined; } function resolveFetchTarget(input: unknown): string { return typeof input === "string" ? input : input instanceof URL ? input.toString() : input instanceof Request ? input.url : String(input); } function stubGitHubCatalogFetch() { const fetchMock = vi.fn(async (input: unknown) => { const target = resolveFetchTarget(input); const response = buildCopilotSetupResponse(target); if (response) { return response; } throw new Error(`unexpected fetch in github-copilot catalog stub: ${target}`); }); vi.stubGlobal("fetch", fetchMock); return fetchMock; } it("keeps existing device auth results provider-owned", async () => { const provider = await getProvider(); stubGitHubCatalogFetch(); state.authStore.profiles["github-copilot:github"] = { type: "token", provider: "github-copilot", token: "github-device-token", }; const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; const hadOwnIsTTY = Object.hasOwn(stdin, "isTTY"); const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); Object.defineProperty(stdin, "isTTY", { configurable: true, enumerable: true, get: () => true, }); try { const result = await provider.auth[0]?.run(buildAuthContext() as never); expect(result).toEqual({ profiles: [ { profileId: "github-copilot:github", credential: { type: "token", provider: "github-copilot", token: "github-device-token", }, }, ], defaultModel, }); } finally { if (previousIsTTYDescriptor) { Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); } else if (!hadOwnIsTTY) { delete (stdin as { isTTY?: boolean }).isTTY; } } }); function stubGitHubDeviceFlowFetch( outcome: { accessToken: string } | { error: "access_denied" | "expired_token" }, ) { const fetchMock = vi.fn(async (input: unknown) => { const target = resolveFetchTarget(input); if (target === "https://github.com/login/device/code") { return new Response( JSON.stringify({ device_code: "device-code-stub", user_code: "ABCD-1234", verification_uri: "https://github.com/login/device", expires_in: 900, interval: 0, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } if (target === "https://github.com/login/oauth/access_token") { const body = "accessToken" in outcome ? { access_token: outcome.accessToken, token_type: "bearer" } : { error: outcome.error }; return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" }, }); } const setupResponse = buildCopilotSetupResponse(target); if (setupResponse) { return setupResponse; } throw new Error(`unexpected fetch in github-copilot device flow stub: ${target}`); }); vi.stubGlobal("fetch", fetchMock); return fetchMock; } function buildSpyAuthContext() { const ctx = buildAuthContext() as ReturnType & { openUrl: (url: string) => Promise; prompter: WizardPrompter; }; ctx.openUrl = vi.fn(async () => {}); ctx.prompter.note = vi.fn(async () => {}); return ctx; } afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); it("keeps device auth results provider-owned", async () => { const provider = await getProvider(); stubGitHubDeviceFlowFetch({ accessToken: "github-device-token" }); const ctx = buildSpyAuthContext(); const result = await runDeviceAuthWithFakeTimers( () => provider.auth[0]?.run(ctx as never), ctx.openUrl, ); expect(result).toEqual({ profiles: [ { profileId: "github-copilot:github", credential: { type: "token", provider: "github-copilot", token: "github-device-token", }, secretStorage: { kind: "store", namePrefix: "GITHUB_COPILOT_TOKEN", }, }, ], defaultModel, }); // Credential is sourced from the device flow response, not from the existing // on-disk auth store. ensureAuthProfileStore is still called by the // resolveExistingCopilotAuthResult existence check, which legitimately probes // the store before launching the device flow when no profile exists yet. }); it("uses the wizard prompter and openUrl hooks for the device code (no stdin/stdout)", async () => { const provider = await getProvider(); stubGitHubDeviceFlowFetch({ accessToken: "github-device-token" }); const ctx = buildSpyAuthContext(); await runDeviceAuthWithFakeTimers(() => provider.auth[0]?.run(ctx as never), ctx.openUrl); expect(ctx.openUrl).toHaveBeenCalledWith("https://github.com/login/device"); const noteCalls = (ctx.prompter.note as ReturnType).mock.calls; const codeNote = noteCalls.find( ([msg]) => typeof msg === "string" && msg.includes("ABCD-1234"), ); expect(codeNote).toBeDefined(); expect(codeNote?.[0]).toContain("https://github.com/login/device"); }); it("supports non-interactive (GUI/RPC) auth contexts without a TTY", async () => { const provider = await getProvider(); const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; const hadOwnIsTTY = Object.hasOwn(stdin, "isTTY"); const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); Object.defineProperty(stdin, "isTTY", { configurable: true, enumerable: true, get: () => false, }); stubGitHubDeviceFlowFetch({ accessToken: "rpc-client-token" }); const ctx = buildSpyAuthContext(); try { const result = await runDeviceAuthWithFakeTimers( () => provider.auth[0]?.run(ctx as never), ctx.openUrl, ); expect(result?.profiles).toEqual([ { profileId: "github-copilot:github", credential: { type: "token", provider: "github-copilot", token: "rpc-client-token", }, secretStorage: { kind: "store", namePrefix: "GITHUB_COPILOT_TOKEN", }, }, ]); } finally { if (previousIsTTYDescriptor) { Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); } else if (!hadOwnIsTTY) { delete (stdin as { isTTY?: boolean }).isTTY; } } }); it("returns no profiles and notes cancellation when the user denies access", async () => { const provider = await getProvider(); stubGitHubDeviceFlowFetch({ error: "access_denied" }); const ctx = buildSpyAuthContext(); const result = await runDeviceAuthWithFakeTimers( () => provider.auth[0]?.run(ctx as never), ctx.openUrl, ); expect(result).toEqual({ profiles: [] }); const noteCalls = (ctx.prompter.note as ReturnType).mock.calls; expect( noteCalls.some(([msg]) => typeof msg === "string" && msg.toLowerCase().includes("cancel")), ).toBe(true); }); it("returns no profiles and notes expiry when the device code expires", async () => { const provider = await getProvider(); stubGitHubDeviceFlowFetch({ error: "expired_token" }); const ctx = buildSpyAuthContext(); const result = await runDeviceAuthWithFakeTimers( () => provider.auth[0]?.run(ctx as never), ctx.openUrl, ); expect(result).toEqual({ profiles: [] }); const noteCalls = (ctx.prompter.note as ReturnType).mock.calls; expect( noteCalls.some(([msg]) => typeof msg === "string" && msg.toLowerCase().includes("expired")), ).toBe(true); }); }); }