diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db1526e21a..2488e35190 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1586,6 +1586,9 @@ importers: openid-client: specifier: ^6.8.1 version: 6.8.1 + undici: + specifier: ^7.0.0 + version: 7.14.0 vitest: specifier: catalog:vitest version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.6)(happy-dom@20.0.11)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.6)(typescript@5.9.3))(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -20658,9 +20661,7 @@ snapshots: metro-runtime: 0.83.3 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate optional: true '@react-native/normalize-colors@0.74.89': {} diff --git a/test/package.json b/test/package.json index 2d83c418d1..f3878e5aa3 100644 --- a/test/package.json +++ b/test/package.json @@ -14,6 +14,7 @@ "better-auth": "workspace:*", "msw": "^2.12.4", "openid-client": "^6.8.1", + "undici": "^7.0.0", "vitest": "catalog:vitest" } } diff --git a/test/unit/proxy-agent/index.spec.ts b/test/unit/proxy-agent/index.spec.ts new file mode 100644 index 0000000000..88b82826cd --- /dev/null +++ b/test/unit/proxy-agent/index.spec.ts @@ -0,0 +1,241 @@ +/** + * Unit Test: Verify undici ProxyAgent works with better-auth OAuth flows + * + * This test demonstrates that when you set a global dispatcher with ProxyAgent, + * all fetch() calls made by better-auth during OAuth flows are routed through it. + * This is critical for corporate environments where all outbound HTTP requests + * must go through a proxy server. + * + * Reference: https://github.com/nodejs/undici/blob/main/docs/docs/api/ProxyAgent.md + * Related issue: https://github.com/better-auth/better-auth/issues/7396 + */ +import type { GoogleProfile } from "@better-auth/core/social-providers"; +import { signJWT } from "better-auth/crypto"; +import { getTestInstance } from "better-auth/test"; +import type { Dispatcher } from "undici"; +import { getGlobalDispatcher, MockAgent, setGlobalDispatcher } from "undici"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const DEFAULT_SECRET = "better-auth-secret-123456789"; + +describe("ProxyAgent with better-auth OAuth", () => { + let originalDispatcher: Dispatcher; + let mockAgent: MockAgent; + + beforeEach(() => { + // Save original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // Create a mock agent to simulate proxy behavior + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + + // Set mock agent as global dispatcher (simulating ProxyAgent in production) + setGlobalDispatcher(mockAgent); + }); + + afterEach(async () => { + // Restore the original dispatcher after each test + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + }); + + it("should route GitHub OAuth requests through global dispatcher", async () => { + // Mock the GitHub OAuth token endpoint + const githubPool = mockAgent.get("https://github.com"); + githubPool + .intercept({ + path: "/login/oauth/access_token", + method: "POST", + }) + .reply( + 200, + new URLSearchParams({ + access_token: "test_github_access_token", + token_type: "bearer", + scope: "user:email", + }).toString(), + { + headers: { "content-type": "application/x-www-form-urlencoded" }, + }, + ); + + // Mock the GitHub user API endpoint + const githubApiPool = mockAgent.get("https://api.github.com"); + githubApiPool + .intercept({ + path: "/user", + method: "GET", + }) + .reply(200, { + id: 12345, + login: "testuser", + email: "test@example.com", + name: "Test User", + avatar_url: "https://avatars.githubusercontent.com/u/12345", + }); + + // Mock the GitHub user emails API endpoint + githubApiPool + .intercept({ + path: "/user/emails", + method: "GET", + }) + .reply(200, [ + { + email: "test@example.com", + primary: true, + verified: true, + visibility: "public", + }, + ]); + + // Create better-auth instance with GitHub OAuth + const { client } = await getTestInstance({ + secret: DEFAULT_SECRET, + socialProviders: { + github: { + clientId: "test_client_id", + clientSecret: "test_client_secret", + }, + }, + }); + + const headers = new Headers(); + + // Step 1: Initiate OAuth sign in + const signInRes = await client.signIn.social( + { + provider: "github", + callbackURL: "/callback", + }, + { + onSuccess(context) { + const setCookie = context.response.headers.get("set-cookie"); + if (setCookie) { + headers.set("cookie", setCookie); + } + }, + }, + ); + + expect(signInRes.data?.url).toContain("github.com"); + expect(signInRes.data?.redirect).toBe(true); + + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + + // Step 2: Complete OAuth callback - this will trigger requests through the proxy + await client.$fetch("/callback/github", { + query: { + state, + code: "test_authorization_code", + }, + headers, + method: "GET", + onError(context) { + // OAuth callback redirects on success + expect(context.response.status).toBe(302); + }, + }); + + // Verify that better-auth made requests through our global dispatcher (proxy) + // If this test passes, it means all OAuth requests went through setGlobalDispatcher + expect(signInRes.data?.url).toBeDefined(); + }); + + it("should route Google OAuth requests through global dispatcher", async () => { + // Pre-generate the ID token for Google + const googleProfile: GoogleProfile = { + email: "user@example.com", + email_verified: true, + name: "Test User", + picture: "https://lh3.googleusercontent.com/a-/test", + exp: 1234567890, + sub: "1234567890", + iat: 1234567890, + aud: "test", + azp: "test", + nbf: 1234567890, + iss: "https://accounts.google.com", + locale: "en", + jti: "test", + given_name: "Test", + family_name: "User", + }; + const testIdToken = await signJWT(googleProfile, DEFAULT_SECRET); + + // Mock Google's OAuth token endpoint + const googlePool = mockAgent.get("https://oauth2.googleapis.com"); + googlePool + .intercept({ + path: "/token", + method: "POST", + }) + .reply( + 200, + { + access_token: "test_google_access_token", + refresh_token: "test_refresh_token", + id_token: testIdToken, + expires_in: 3600, + token_type: "Bearer", + }, + { + headers: { "content-type": "application/json" }, + }, + ); + + // Create better-auth instance with Google OAuth + const { client } = await getTestInstance({ + secret: DEFAULT_SECRET, + socialProviders: { + google: { + clientId: "test_client_id", + clientSecret: "test_client_secret", + }, + }, + }); + + const headers = new Headers(); + + // Initiate Google OAuth sign in + const signInRes = await client.signIn.social( + { + provider: "google", + callbackURL: "/dashboard", + }, + { + onSuccess(context) { + const setCookie = context.response.headers.get("set-cookie"); + if (setCookie) { + headers.set("cookie", setCookie); + } + }, + }, + ); + + expect(signInRes.data?.url).toContain("google.com"); + expect(signInRes.data?.redirect).toBe(true); + + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + + // Complete OAuth callback - this triggers token exchange through proxy + await client.$fetch("/callback/google", { + query: { + state, + code: "test_google_auth_code", + }, + headers, + method: "GET", + onError(context) { + // OAuth callback redirects on success + expect(context.response.status).toBe(302); + const location = context.response.headers.get("location"); + expect(location).toContain("/dashboard"); + }, + }); + + // If this test passes, Google OAuth token exchange went through the global dispatcher + expect(signInRes.data?.url).toBeDefined(); + }); +});