diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da539ae005..02d7c318a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,7 +416,7 @@ importers: version: 12.23.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3) geist: specifier: ^1.4.2 - version: 1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) + version: 1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -528,7 +528,7 @@ importers: version: 2.1.1 geist: specifier: ^1.4.2 - version: 1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) + version: 1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) lucide-react: specifier: ^0.542.0 version: 0.542.0(react@19.2.3) @@ -770,7 +770,7 @@ importers: version: 16.4.3(@oramacloud/client@2.1.4)(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(algoliasearch@5.46.2)(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.4) geist: specifier: ^1.4.2 - version: 1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) + version: 1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -1601,6 +1601,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) @@ -18254,7 +18257,7 @@ snapshots: postcss: 8.4.49 resolve-from: 5.0.0 optionalDependencies: - expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.80.2(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.28.5))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) transitivePeerDependencies: - bufferutil - supports-color @@ -18341,7 +18344,7 @@ snapshots: '@expo/json-file': 10.0.8 '@react-native/normalize-colors': 0.81.5 debug: 4.4.3 - expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.80.2(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.28.5))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) resolve-from: 5.0.0 semver: 7.7.3 xml2js: 0.6.0 @@ -20985,9 +20988,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': {} @@ -23080,7 +23081,7 @@ snapshots: resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.28.4 - expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.80.2(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.28.5))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -24818,7 +24819,7 @@ snapshots: expo-keep-awake@15.0.8(expo@54.0.30)(react@19.2.3): dependencies: - expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.80.2(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.28.5))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) react: 19.2.3 expo-linking@7.1.7(expo@54.0.30)(react-native@0.80.2(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3): @@ -25586,7 +25587,7 @@ snapshots: function-bind@1.1.2: {} - geist@1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)): + geist@1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)): dependencies: next: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1) 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(); + }); +});