diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..796369b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Test + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install + + - run: bun test diff --git a/bun.lock b/bun.lock index 95145b8..d0f5c07 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "opencode-anthropic-auth", diff --git a/index.mjs b/index.mjs index 3105b69..183c4fa 100644 --- a/index.mjs +++ b/index.mjs @@ -129,7 +129,7 @@ export async function AnthropicAuthPlugin({ client }) { }, body: { type: "oauth", - refresh: json.refresh_token, + refresh: json.refresh_token ?? auth.refresh, access: json.access_token, expires: Date.now() + json.expires_in * 1000, }, diff --git a/index.test.mjs b/index.test.mjs new file mode 100644 index 0000000..07e3961 --- /dev/null +++ b/index.test.mjs @@ -0,0 +1,679 @@ +import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"; + +// Mock fetch globally +const originalFetch = globalThis.fetch; + +describe("AnthropicAuthPlugin", () => { + describe("token refresh", () => { + let mockClient; + let savedAuth; + + beforeEach(() => { + savedAuth = null; + mockClient = { + auth: { + set: mock(async ({ body }) => { + savedAuth = body; + }), + }, + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("preserves existing refresh token when server does not return new one", async () => { + const existingRefreshToken = "existing-refresh-token-123"; + const newAccessToken = "new-access-token-456"; + + // Mock getAuth to return expired OAuth credentials + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: existingRefreshToken, + access: "expired-access-token", + expires: Date.now() - 1000, // expired + })); + + // Mock fetch to simulate token refresh response WITHOUT refresh_token + globalThis.fetch = mock(async (url, options) => { + if (url === "https://console.anthropic.com/v1/oauth/token") { + return new Response( + JSON.stringify({ + access_token: newAccessToken, + expires_in: 3600, + // Note: no refresh_token in response + }), + { status: 200 }, + ); + } + // For the actual API request + return new Response("{}", { status: 200 }); + }); + + // Import and create plugin + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + + // Get the loader and create the fetch wrapper + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + // Make a request that triggers token refresh + await loaderResult.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + + // Verify that auth.set was called with the preserved refresh token + expect(mockClient.auth.set).toHaveBeenCalled(); + expect(savedAuth).not.toBeNull(); + expect(savedAuth.refresh).toBe(existingRefreshToken); + expect(savedAuth.access).toBe(newAccessToken); + }); + + it("uses new refresh token when server returns one", async () => { + const existingRefreshToken = "existing-refresh-token-123"; + const newRefreshToken = "new-refresh-token-789"; + const newAccessToken = "new-access-token-456"; + + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: existingRefreshToken, + access: "expired-access-token", + expires: Date.now() - 1000, + })); + + globalThis.fetch = mock(async (url) => { + if (url === "https://console.anthropic.com/v1/oauth/token") { + return new Response( + JSON.stringify({ + access_token: newAccessToken, + refresh_token: newRefreshToken, + expires_in: 3600, + }), + { status: 200 }, + ); + } + return new Response("{}", { status: 200 }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + await loaderResult.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + + expect(savedAuth.refresh).toBe(newRefreshToken); + expect(savedAuth.access).toBe(newAccessToken); + }); + + it("throws error when token refresh fails", async () => { + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: "some-refresh-token", + access: "expired-access-token", + expires: Date.now() - 1000, + })); + + globalThis.fetch = mock(async (url) => { + if (url === "https://console.anthropic.com/v1/oauth/token") { + return new Response("Unauthorized", { status: 401 }); + } + return new Response("{}", { status: 200 }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + await expect( + loaderResult.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }), + ).rejects.toThrow("Token refresh failed: 401"); + }); + + it("does not refresh when token is still valid", async () => { + const validAccessToken = "valid-access-token"; + let tokenRefreshAttempted = false; + + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: "some-refresh-token", + access: validAccessToken, + expires: Date.now() + 3600000, // valid for 1 hour + })); + + globalThis.fetch = mock(async (url) => { + if (url === "https://console.anthropic.com/v1/oauth/token") { + tokenRefreshAttempted = true; + return new Response("{}", { status: 200 }); + } + return new Response("{}", { status: 200 }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + await loaderResult.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + + expect(tokenRefreshAttempted).toBe(false); + expect(mockClient.auth.set).not.toHaveBeenCalled(); + }); + + it("refreshes when access token is missing", async () => { + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: "some-refresh-token", + access: "", // empty/missing + expires: Date.now() + 3600000, + })); + + let tokenRefreshAttempted = false; + globalThis.fetch = mock(async (url) => { + if (url === "https://console.anthropic.com/v1/oauth/token") { + tokenRefreshAttempted = true; + return new Response( + JSON.stringify({ + access_token: "new-token", + expires_in: 3600, + }), + { status: 200 }, + ); + } + return new Response("{}", { status: 200 }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + await loaderResult.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + body: "{}", + }); + + expect(tokenRefreshAttempted).toBe(true); + }); + }); + + describe("request headers", () => { + let mockClient; + + beforeEach(() => { + mockClient = { + auth: { + set: mock(async () => {}), + }, + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("sets required OAuth headers on requests", async () => { + let capturedHeaders; + + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 3600000, + })); + + globalThis.fetch = mock(async (url, init) => { + const urlStr = url instanceof URL ? url.toString() : url; + if (urlStr.includes("api.anthropic.com/v1/messages")) { + capturedHeaders = init?.headers; + } + return new Response("{}", { status: 200 }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + await loaderResult.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + body: "{}", + }); + + expect(capturedHeaders.get("authorization")).toBe("Bearer access-token"); + expect(capturedHeaders.get("anthropic-beta")).toContain( + "oauth-2025-04-20", + ); + expect(capturedHeaders.get("user-agent")).toContain("claude-cli"); + expect(capturedHeaders.get("x-api-key")).toBeNull(); + }); + + it("preserves incoming beta headers while adding required ones", async () => { + let capturedHeaders; + + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 3600000, + })); + + globalThis.fetch = mock(async (url, init) => { + const urlStr = url instanceof URL ? url.toString() : url; + if (urlStr.includes("api.anthropic.com/v1/messages")) { + capturedHeaders = init?.headers; + } + return new Response("{}", { status: 200 }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + await loaderResult.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "anthropic-beta": "custom-beta-flag", + }, + body: "{}", + }); + + const betaHeader = capturedHeaders.get("anthropic-beta"); + expect(betaHeader).toContain("oauth-2025-04-20"); + expect(betaHeader).toContain("custom-beta-flag"); + }); + }); + + describe("tool name prefixing", () => { + let mockClient; + + beforeEach(() => { + mockClient = { + auth: { + set: mock(async () => {}), + }, + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("prefixes tool names with mcp_", async () => { + let capturedBody; + + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 3600000, + })); + + globalThis.fetch = mock(async (url, init) => { + const urlStr = url instanceof URL ? url.toString() : url; + if (urlStr.includes("api.anthropic.com/v1/messages")) { + capturedBody = init?.body; + } + return new Response("{}", { status: 200 }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + await loaderResult.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + body: JSON.stringify({ + tools: [{ name: "read_file" }, { name: "write_file" }], + }), + }); + + const parsed = JSON.parse(capturedBody); + expect(parsed.tools[0].name).toBe("mcp_read_file"); + expect(parsed.tools[1].name).toBe("mcp_write_file"); + }); + + it("prefixes tool_use blocks in messages", async () => { + let capturedBody; + + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 3600000, + })); + + globalThis.fetch = mock(async (url, init) => { + const urlStr = url instanceof URL ? url.toString() : url; + if (urlStr.includes("api.anthropic.com/v1/messages")) { + capturedBody = init?.body; + } + return new Response("{}", { status: 200 }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + await loaderResult.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + body: JSON.stringify({ + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", name: "read_file", id: "123" }], + }, + ], + }), + }); + + const parsed = JSON.parse(capturedBody); + expect(parsed.messages[0].content[0].name).toBe("mcp_read_file"); + }); + + it("strips mcp_ prefix from response tool names", async () => { + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 3600000, + })); + + globalThis.fetch = mock(async () => { + return new Response('data: {"name": "mcp_read_file"}\n\n', { + status: 200, + }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + const response = await loaderResult.fetch( + "https://api.anthropic.com/v1/messages", + { + method: "POST", + body: "{}", + }, + ); + + const text = await response.text(); + expect(text).toContain('"name": "read_file"'); + expect(text).not.toContain("mcp_read_file"); + }); + }); + + describe("system prompt sanitization", () => { + let mockClient; + + beforeEach(() => { + mockClient = { + auth: { + set: mock(async () => {}), + }, + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("replaces OpenCode with Claude Code in system prompts", async () => { + let capturedBody; + + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 3600000, + })); + + globalThis.fetch = mock(async (url, init) => { + const urlStr = url instanceof URL ? url.toString() : url; + if (urlStr.includes("api.anthropic.com/v1/messages")) { + capturedBody = init?.body; + } + return new Response("{}", { status: 200 }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + await loaderResult.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + body: JSON.stringify({ + system: [{ type: "text", text: "You are OpenCode assistant" }], + }), + }); + + const parsed = JSON.parse(capturedBody); + expect(parsed.system[0].text).toBe("You are Claude Code assistant"); + }); + + it("preserves opencode in file paths", async () => { + let capturedBody; + + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 3600000, + })); + + globalThis.fetch = mock(async (url, init) => { + const urlStr = url instanceof URL ? url.toString() : url; + if (urlStr.includes("api.anthropic.com/v1/messages")) { + capturedBody = init?.body; + } + return new Response("{}", { status: 200 }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ client: mockClient }); + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + await loaderResult.fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + body: JSON.stringify({ + system: [{ type: "text", text: "File at /path/to/opencode-plugin" }], + }), + }); + + const parsed = JSON.parse(capturedBody); + expect(parsed.system[0].text).toContain("/path/to/opencode-plugin"); + }); + }); + + describe("exchange function", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns failed type on non-ok response", async () => { + globalThis.fetch = mock(async () => { + return new Response("Bad Request", { status: 400 }); + }); + + // We need to test the exchange function indirectly through the auth method + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ + client: { auth: { set: mock() } }, + }); + + const authMethod = plugin.auth.methods[0]; + const { callback } = await authMethod.authorize(); + const result = await callback("invalid-code"); + + expect(result.type).toBe("failed"); + }); + + it("returns success with tokens on valid exchange", async () => { + globalThis.fetch = mock(async () => { + return new Response( + JSON.stringify({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 3600, + }), + { status: 200 }, + ); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ + client: { auth: { set: mock() } }, + }); + + const authMethod = plugin.auth.methods[0]; + const { callback } = await authMethod.authorize(); + const result = await callback("valid-code#state"); + + expect(result.type).toBe("success"); + expect(result.refresh).toBe("new-refresh"); + expect(result.access).toBe("new-access"); + }); + }); + + describe("non-oauth auth", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns empty object for non-oauth auth", async () => { + const mockGetAuth = mock(() => ({ + type: "api", + key: "sk-ant-xxx", + })); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ + client: { auth: { set: mock() } }, + }); + + const loaderResult = await plugin.auth.loader(mockGetAuth, { + models: {}, + }); + + expect(loaderResult).toEqual({}); + }); + + it("passes through fetch for non-oauth auth type in fetch wrapper", async () => { + let passedThrough = false; + + const mockGetAuth = mock(() => ({ + type: "api", + key: "sk-ant-xxx", + })); + + globalThis.fetch = mock(async () => { + passedThrough = true; + return new Response("{}", { status: 200 }); + }); + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ + client: { auth: { set: mock() } }, + }); + + // First call returns oauth to get the fetch wrapper + let callCount = 0; + const mixedGetAuth = mock(() => { + callCount++; + if (callCount === 1) { + return { + type: "oauth", + refresh: "r", + access: "a", + expires: Date.now() + 3600000, + }; + } + // Subsequent calls return non-oauth + return { type: "api", key: "sk-ant-xxx" }; + }); + + const loaderResult = await plugin.auth.loader(mixedGetAuth, { + models: {}, + }); + + await loaderResult.fetch("https://api.anthropic.com/v1/messages", {}); + + expect(passedThrough).toBe(true); + }); + }); + + describe("model cost zeroing", () => { + it("zeros out model costs for oauth auth", async () => { + const mockGetAuth = mock(() => ({ + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 3600000, + })); + + const provider = { + models: { + "claude-3-opus": { + cost: { input: 15, output: 75, cache: { read: 1, write: 2 } }, + }, + "claude-3-sonnet": { + cost: { input: 3, output: 15, cache: { read: 0.5, write: 1 } }, + }, + }, + }; + + const { AnthropicAuthPlugin } = await import("./index.mjs"); + const plugin = await AnthropicAuthPlugin({ + client: { auth: { set: mock() } }, + }); + + await plugin.auth.loader(mockGetAuth, provider); + + expect(provider.models["claude-3-opus"].cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }); + expect(provider.models["claude-3-sonnet"].cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }); + }); + }); +}); diff --git a/package.json b/package.json index 1a828dd..0b53614 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,10 @@ { "name": "opencode-anthropic-auth", - "version": "0.0.13", + "version": "0.0.14", "main": "./index.mjs", + "scripts": { + "test": "bun test" + }, "devDependencies": { "@opencode-ai/plugin": "^0.4.45" },