next-auth icon indicating copy to clipboard operation
next-auth copied to clipboard

V5 Fails Behind Corporate Proxy with Provider

Open SolidAnonDev opened this issue 1 year ago • 15 comments

Environment

OS: Windows 11 10.0.22621
CPU: (12) x64 Intel(R) Core(TM) i7-8700 CPU @ 3.20GHz
Memory: 6.80 GB / 15.78 GB
Binaries:
Node: 20.9.0 - C:\Program Files\nodejs\node.EXE
Yarn: 1.22.19 - C:\Program Files (x86)\Yarn\bin\yarn.CMD
npm: 10.8.0 - C:\Program Files\nodejs\npm.CMD
Browsers:
Edge: Chromium (125.0.2535.51)
Internet Explorer: 11.0.22621.3527
npmPackages:
next: ^14.2.3 => 14.2.3
next-auth: ^5.0.0-beta.18 <= 5.0.0-beta.18
react: ^18.3.1 => 18.3.1

Reproduction URL

https://github.com/tbrundige/authjs-adapter-issue

Describe the issue

First off, I pulled a random repro link from other open issues to make it valid for the bot. This isn't really something I can reproduce. The reproduction is to try to use next-auth with a provider behind a corporate proxy, there's not really a minimal reproduction for that. I'm sorry for doing this but there's not much I can do to reproduce this.

Next-Auth, just as in #2509 - appears to fail behind a corporate proxy in V5 as well. We are using the Microsoft Entra ID provider, coming from the V4 Azure AD Provider.

For V4, we had been using this solution which was coined in the issue linked above, involving patching the next-auth source code with patch-package. This worked fine, but it appears things have changed a great deal in the V5 source and we cannot find where we should patch the source to allow the requests to make it through.

If support cannot be added for a corporate proxy with providers, we would like to at least see how/where to apply a patch to the source to make this workable as we did in V4. If not, this may completely bar and shutdown any ability to use next-auth moving forward when V5 is released, which would be really unfortunate for us.

V5 is working in development for us (Microsoft Entra Id Provider) without issue, with successful authentication, but upon deployment, we receive errors very remeniscent of what we saw with V4 behind a corporate proxy.

How to reproduce

ust run any application with NextAuth behind a corporate proxy and try to log in with a Provider (like Google or GitHub).

You will see something similar to the following:

message: 'fetch failed',
    stack: 'TypeError: fetch failed\n' +
      '    at Object.processResponse (node:internal/deps/undici/undici:5555:34)\n' 

Expected behavior

The requests are able to make it out to the providers for successful authentication from behind a corporate proxy. In previous iterations, we acheived this using an HttpProxyAgent.

SolidAnonDev avatar May 28 '24 13:05 SolidAnonDev

So I've been having issues with corporate proxy as well for both v4 and v5 but have managed to get it working on v5 with the following code

import { ProxyAgent } from 'undici';

const discoveryResponse = await o.discoveryRequest(issuer,
        {
          [o.customFetch]: (...args) => {
            if (process.env.http_proxy) {
              const agent =  new ProxyAgent(process.env.http_proxy);
              args[1].dispatcher = agent;
            }
            return fetch(...args);
          }
        });

Below is the full (messy and auto-formatted) patch I've applied. I haven't tested the change for the profile image fetch but that's something I don't need so just applied the change in case I do in the future and based it on the v4 change.

Run an npm i undici

diff --git a/node_modules/@auth/core/lib/actions/callback/oauth/callback.js b/node_modules/@auth/core/lib/actions/callback/oauth/callback.js
index e4e64ca..f912fc7 100644
--- a/node_modules/@auth/core/lib/actions/callback/oauth/callback.js
+++ b/node_modules/@auth/core/lib/actions/callback/oauth/callback.js
@@ -1,6 +1,8 @@
 import * as checks from "./checks.js";
 import * as o from "oauth4webapi";
 import { OAuthCallbackError, OAuthProfileParseError, } from "../../../../errors.js";
+import { ProxyAgent } from 'undici';
+
 /**
  * Handles the following OAuth steps.
  * https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1
@@ -20,7 +22,16 @@ export async function handleOAuth(query, cookies, options, randomState) {
         // We assume that issuer is always defined as this has been asserted earlier
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         const issuer = new URL(provider.issuer);
-        const discoveryResponse = await o.discoveryRequest(issuer);
+        const discoveryResponse = await o.discoveryRequest(issuer,
+          {
+            [o.customFetch]: (...args) => {
+              if (process.env.http_proxy) {
+                const agent =  new ProxyAgent(process.env.http_proxy);
+                args[1].dispatcher = agent;
+              }
+              return fetch(...args);
+            },
+          });
         const discoveredAs = await o.processDiscoveryResponse(issuer, discoveryResponse);
         if (!discoveredAs.token_endpoint)
             throw new TypeError("TODO: Authorization server did not provide a token endpoint.");
@@ -61,6 +72,9 @@ export async function handleOAuth(query, cookies, options, randomState) {
                 args[1]?.body instanceof URLSearchParams) {
                 args[1].body.delete("code_verifier");
             }
+            if (process.env.http_proxy) {
+              args[1].dispatcher = new ProxyAgent(process.env.http_proxy);
+            }
             return fetch(...args);
         },
     });
diff --git a/node_modules/@auth/core/lib/actions/signin/authorization-url.js b/node_modules/@auth/core/lib/actions/signin/authorization-url.js
index 8f093cb..6553c77 100644
--- a/node_modules/@auth/core/lib/actions/signin/authorization-url.js
+++ b/node_modules/@auth/core/lib/actions/signin/authorization-url.js
@@ -1,5 +1,7 @@
 import * as checks from "../callback/oauth/checks.js";
 import * as o from "oauth4webapi";
+import { ProxyAgent } from 'undici';
+
 /**
  * Generates an authorization/request token URL.
  *
@@ -15,7 +17,16 @@ export async function getAuthorizationUrl(query, options) {
         // We check this in assert.ts
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         const issuer = new URL(provider.issuer);
-        const discoveryResponse = await o.discoveryRequest(issuer);
+        const discoveryResponse = await o.discoveryRequest(issuer,
+        {
+          [o.customFetch]: (...args) => {
+            if (process.env.http_proxy) {
+              const agent =  new ProxyAgent(process.env.http_proxy);
+              args[1].dispatcher = agent;
+            }
+            return fetch(...args);
+          }
+        });
         const as = await o.processDiscoveryResponse(issuer, discoveryResponse);
         if (!as.authorization_endpoint) {
             throw new TypeError("Authorization server did not provide an authorization endpoint.");
diff --git a/node_modules/@auth/core/providers/microsoft-entra-id.js b/node_modules/@auth/core/providers/microsoft-entra-id.js
index 2063d5e..8b45cb6 100644
--- a/node_modules/@auth/core/providers/microsoft-entra-id.js
+++ b/node_modules/@auth/core/providers/microsoft-entra-id.js
@@ -100,6 +100,8 @@
  *
  * :::
  */
+import { ProxyAgent } from "undici";
+
 export default function MicrosoftEntraID(options) {
     const { tenantId = "common", profilePhotoSize = 48, ...rest } = options;
     rest.issuer ?? (rest.issuer = `https://login.microsoftonline.com/${tenantId}/v2.0`);
@@ -110,12 +112,21 @@ export default function MicrosoftEntraID(options) {
         wellKnown: `${rest.issuer}/.well-known/openid-configuration?appid=${options.clientId}`,
         authorization: {
             params: {
-                scope: "openid profile email User.Read",
-            },
+                scope: "openid profile email User.Read"
+            }
         },
         async profile(profile, tokens) {
             // https://learn.microsoft.com/en-us/graph/api/profilephoto-get?view=graph-rest-1.0&tabs=http#examples
-            const response = await fetch(`https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`, { headers: { Authorization: `Bearer ${tokens.access_token}` } });
+            let fetchOptions = {
+                headers: {
+                    Authorization: `Bearer ${tokens.access_token}`
+                }
+            };
+
+            if (process.env.http_proxy) {
+                fetchOptions.dispatcher = new ProxyAgent(process.env.http_proxy);
+            }
+            const response = await fetch(`https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`, fetchOptions);
             // Confirm that profile photo was returned
             let image;
             // TODO: Do this without Buffer
@@ -124,17 +135,17 @@ export default function MicrosoftEntraID(options) {
                     const pictureBuffer = await response.arrayBuffer();
                     const pictureBase64 = Buffer.from(pictureBuffer).toString("base64");
                     image = `data:image/jpeg;base64, ${pictureBase64}`;
+                } catch {
                 }
-                catch { }
             }
             return {
                 id: profile.sub,
                 name: profile.name,
                 email: profile.email,
-                image: image ?? null,
+                image: image ?? null
             };
         },
         style: { text: "#fff", bg: "#0072c6" },
-        options: rest,
+        options: rest
     };
 }

Using patch-package to generate and apply the change. Hope this helps if you haven't already solved the issue since posting :)

MobliMic avatar Jun 20 '24 17:06 MobliMic

@MobliMic - If this is working for you, this will likely work for me. A quick once over looks really good, as it appears to hit the same things V4 needed to work successfully behind the proxy.

I have been successfully using V4 for over a year with an older corporate proxy patch. I just had no idea how to track down where the problematic fetches are that need a proxy agent for V5. I will try this early next week and report back. Thanks so much for this!

Also, I am not using the profile picture call like you, so I have removed it completely in my patch file.

SolidAnonDev avatar Jun 21 '24 12:06 SolidAnonDev

So I've been having issues with corporate proxy as well for both v4 and v5 but have managed to get it working on v5 with the following code

import { ProxyAgent } from 'undici';

const discoveryResponse = await o.discoveryRequest(issuer,
        {
          [o.customFetch]: (...args) => {
            if (process.env.http_proxy) {
              const agent =  new ProxyAgent(process.env.http_proxy);
              args[1].dispatcher = agent;
            }
            return fetch(...args);
          }
        });

Below is the full (messy and auto-formatted) patch I've applied. I haven't tested the change for the profile image fetch but that's something I don't need so just applied the change in case I do in the future and based it on the v4 change.

Run an npm i undici

diff --git a/node_modules/@auth/core/lib/actions/callback/oauth/callback.js b/node_modules/@auth/core/lib/actions/callback/oauth/callback.js
index e4e64ca..f912fc7 100644
--- a/node_modules/@auth/core/lib/actions/callback/oauth/callback.js
+++ b/node_modules/@auth/core/lib/actions/callback/oauth/callback.js
@@ -1,6 +1,8 @@
 import * as checks from "./checks.js";
 import * as o from "oauth4webapi";
 import { OAuthCallbackError, OAuthProfileParseError, } from "../../../../errors.js";
+import { ProxyAgent } from 'undici';
+
 /**
  * Handles the following OAuth steps.
  * https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1
@@ -20,7 +22,16 @@ export async function handleOAuth(query, cookies, options, randomState) {
         // We assume that issuer is always defined as this has been asserted earlier
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         const issuer = new URL(provider.issuer);
-        const discoveryResponse = await o.discoveryRequest(issuer);
+        const discoveryResponse = await o.discoveryRequest(issuer,
+          {
+            [o.customFetch]: (...args) => {
+              if (process.env.http_proxy) {
+                const agent =  new ProxyAgent(process.env.http_proxy);
+                args[1].dispatcher = agent;
+              }
+              return fetch(...args);
+            },
+          });
         const discoveredAs = await o.processDiscoveryResponse(issuer, discoveryResponse);
         if (!discoveredAs.token_endpoint)
             throw new TypeError("TODO: Authorization server did not provide a token endpoint.");
@@ -61,6 +72,9 @@ export async function handleOAuth(query, cookies, options, randomState) {
                 args[1]?.body instanceof URLSearchParams) {
                 args[1].body.delete("code_verifier");
             }
+            if (process.env.http_proxy) {
+              args[1].dispatcher = new ProxyAgent(process.env.http_proxy);
+            }
             return fetch(...args);
         },
     });
diff --git a/node_modules/@auth/core/lib/actions/signin/authorization-url.js b/node_modules/@auth/core/lib/actions/signin/authorization-url.js
index 8f093cb..6553c77 100644
--- a/node_modules/@auth/core/lib/actions/signin/authorization-url.js
+++ b/node_modules/@auth/core/lib/actions/signin/authorization-url.js
@@ -1,5 +1,7 @@
 import * as checks from "../callback/oauth/checks.js";
 import * as o from "oauth4webapi";
+import { ProxyAgent } from 'undici';
+
 /**
  * Generates an authorization/request token URL.
  *
@@ -15,7 +17,16 @@ export async function getAuthorizationUrl(query, options) {
         // We check this in assert.ts
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         const issuer = new URL(provider.issuer);
-        const discoveryResponse = await o.discoveryRequest(issuer);
+        const discoveryResponse = await o.discoveryRequest(issuer,
+        {
+          [o.customFetch]: (...args) => {
+            if (process.env.http_proxy) {
+              const agent =  new ProxyAgent(process.env.http_proxy);
+              args[1].dispatcher = agent;
+            }
+            return fetch(...args);
+          }
+        });
         const as = await o.processDiscoveryResponse(issuer, discoveryResponse);
         if (!as.authorization_endpoint) {
             throw new TypeError("Authorization server did not provide an authorization endpoint.");
diff --git a/node_modules/@auth/core/providers/microsoft-entra-id.js b/node_modules/@auth/core/providers/microsoft-entra-id.js
index 2063d5e..8b45cb6 100644
--- a/node_modules/@auth/core/providers/microsoft-entra-id.js
+++ b/node_modules/@auth/core/providers/microsoft-entra-id.js
@@ -100,6 +100,8 @@
  *
  * :::
  */
+import { ProxyAgent } from "undici";
+
 export default function MicrosoftEntraID(options) {
     const { tenantId = "common", profilePhotoSize = 48, ...rest } = options;
     rest.issuer ?? (rest.issuer = `https://login.microsoftonline.com/${tenantId}/v2.0`);
@@ -110,12 +112,21 @@ export default function MicrosoftEntraID(options) {
         wellKnown: `${rest.issuer}/.well-known/openid-configuration?appid=${options.clientId}`,
         authorization: {
             params: {
-                scope: "openid profile email User.Read",
-            },
+                scope: "openid profile email User.Read"
+            }
         },
         async profile(profile, tokens) {
             // https://learn.microsoft.com/en-us/graph/api/profilephoto-get?view=graph-rest-1.0&tabs=http#examples
-            const response = await fetch(`https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`, { headers: { Authorization: `Bearer ${tokens.access_token}` } });
+            let fetchOptions = {
+                headers: {
+                    Authorization: `Bearer ${tokens.access_token}`
+                }
+            };
+
+            if (process.env.http_proxy) {
+                fetchOptions.dispatcher = new ProxyAgent(process.env.http_proxy);
+            }
+            const response = await fetch(`https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`, fetchOptions);
             // Confirm that profile photo was returned
             let image;
             // TODO: Do this without Buffer
@@ -124,17 +135,17 @@ export default function MicrosoftEntraID(options) {
                     const pictureBuffer = await response.arrayBuffer();
                     const pictureBase64 = Buffer.from(pictureBuffer).toString("base64");
                     image = `data:image/jpeg;base64, ${pictureBase64}`;
+                } catch {
                 }
-                catch { }
             }
             return {
                 id: profile.sub,
                 name: profile.name,
                 email: profile.email,
-                image: image ?? null,
+                image: image ?? null
             };
         },
         style: { text: "#fff", bg: "#0072c6" },
-        options: rest,
+        options: rest
     };
 }

Using patch-package to generate and apply the change. Hope this helps if you haven't already solved the issue since posting :)

I've tried to use your patch in my NextJs Project and i run into this error:

With --turbo: image

Without --turbo: image

Is there something that i need to configure in nextjs to make it work?

Charismara avatar Jun 27 '24 06:06 Charismara

We applied MobliMic's patch as is on our app and got it working. All we had to do was create a new patch file called next-auth+5.0.0-beta.19.patch and paste their patch in. Previously we couldn't get the other suggested patch for V4 working, so this is great!

We did run into a slight hiccup with the .env. The patch uses lowercase key names and our .env had uppercase. Adding a lowercase key did the trick.

Oakwhisper avatar Jun 27 '24 14:06 Oakwhisper

I still wasn't able to make this patch work for me.

I'm using the patch function of pnpm instead of patch-package with "next": "14.2.4" and "next-auth": "^5.0.0-beta.19".

@[email protected] file
diff --git a/lib/actions/callback/oauth/callback.js b/lib/actions/callback/oauth/callback.js
index e4e64ca424e3f46ccf3924d8b6e620ad75697b65..61f716840e37377e0f4191fbb932bddcb6ebcee4 100644
--- a/lib/actions/callback/oauth/callback.js
+++ b/lib/actions/callback/oauth/callback.js
@@ -1,6 +1,8 @@
 import * as checks from "./checks.js";
 import * as o from "oauth4webapi";
 import { OAuthCallbackError, OAuthProfileParseError, } from "../../../../errors.js";
+import {customFetch} from "oauth4webapi";
+import {ProxyAgent} from "undici";
 /**
  * Handles the following OAuth steps.
  * https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1
@@ -21,7 +23,15 @@ export async function handleOAuth(query, cookies, options, randomState) {
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         const issuer = new URL(provider.issuer);
         const discoveryResponse = await o.discoveryRequest(issuer);
-        const discoveredAs = await o.processDiscoveryResponse(issuer, discoveryResponse);
+        const discoveredAs = await o.processDiscoveryResponse(issuer, discoveryResponse, {
+            [customFetch]: (input, init) => {
+                if(!init) init = {};
+                if(process.env.HTTPS_PROXY) {
+                    init.dispatcher = new ProxyAgent(process.env.HTTPS_PROXY);
+                }
+                return fetch(input, init);
+            }
+        });
         if (!discoveredAs.token_endpoint)
             throw new TypeError("TODO: Authorization server did not provide a token endpoint.");
         if (!discoveredAs.userinfo_endpoint)
@@ -61,6 +71,9 @@ export async function handleOAuth(query, cookies, options, randomState) {
                 args[1]?.body instanceof URLSearchParams) {
                 args[1].body.delete("code_verifier");
             }
+            if (process.env.HTTPS_PROXY) {
+               args[1].dispatcher = new ProxyAgent(process.env.HTTPS_PROXY);
+            }
             return fetch(...args);
         },
     });
diff --git a/lib/actions/signin/authorization-url.js b/lib/actions/signin/authorization-url.js
index 8f093cb7ad48c9b5969ffe1da7fcd7b02ea1dbf2..408c7c19ba528b4854fe31a9da24f0f89c4b6b2e 100644
--- a/lib/actions/signin/authorization-url.js
+++ b/lib/actions/signin/authorization-url.js
@@ -1,5 +1,7 @@
 import * as checks from "../callback/oauth/checks.js";
 import * as o from "oauth4webapi";
+import {customFetch} from "oauth4webapi";
+import {ProxyAgent} from "undici";
 /**
  * Generates an authorization/request token URL.
  *
@@ -15,7 +17,15 @@ export async function getAuthorizationUrl(query, options) {
         // We check this in assert.ts
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         const issuer = new URL(provider.issuer);
-        const discoveryResponse = await o.discoveryRequest(issuer);
+        const discoveryResponse = await o.discoveryRequest(issuer, {
+            [customFetch]: (input, init) => {
+                if(!init) init = {};
+                if(process.env.HTTPS_PROXY) {
+                    init.dispatcher = new ProxyAgent(process.env.HTTPS_PROXY);
+                }
+                return fetch(input, init);
+            }
+        });
         const as = await o.processDiscoveryResponse(issuer, discoveryResponse);
         if (!as.authorization_endpoint) {
             throw new TypeError("Authorization server did not provide an authorization endpoint.");
diff --git a/package.json b/package.json
index e2d3af63a37d3f4b2cb922c347cd64126e343ef4..c4499b5539623db707ecfce3f5678b901ca4d34b 100644
--- a/package.json
+++ b/package.json
@@ -73,7 +73,8 @@
   "peerDependencies": {
     "@simplewebauthn/browser": "^9.0.1",
     "@simplewebauthn/server": "^9.0.2",
-    "nodemailer": "^6.8.0"
+    "nodemailer": "^6.8.0",
+    "undici": "^6.19.2"
   },
   "peerDependenciesMeta": {
     "@simplewebauthn/browser": {
Errors

With --turbo: image

Without --turbo: image

Any Ideas what could be wrong?

Charismara avatar Jul 01 '24 06:07 Charismara

I was briefly attempting to use the patch the other day, but I ran into an issue with Undici and Next 14 with the error:

This error is referenced in this issue here

Module parse failed: Unexpected token (884:57)
|       // 5. If object is not a default iterator object for interface,
|       //    then throw a TypeError.
>       if (typeof this !== 'object' || this === null || !(#target in this)) {
|         throw new TypeError(
|           `'next' called on an object that does not implement interface ${name} Iterator.`

Import trace for requested module:
./node_modules/undici/lib/web/fetch/util.js
./node_modules/undici/lib/web/fetch/index.js
./node_modules/undici/index.js
./node_modules/@elastic/transport/lib/connection/UndiciConnection.js
./node_modules/@elastic/transport/lib/connection/index.js
./node_modules/@elastic/transport/index.js
./node_modules/@elastic/elasticsearch/index.js
./src/search/client.ts
./src/search/search.ts

This appears to be an issue with next and not undici, though I did apply the patch as@[email protected] and not next-auth+5.0.0-beta.19.patch so maybe I will try that and see what happens.

@Charismara I just realized this is the same issue you're having - this is a problem with next parsing Undici, and I'm not quite sure how to get around it or what versions of Next the other users are using in order to resolve this issue.

SolidAnonDev avatar Jul 01 '24 12:07 SolidAnonDev

We applied MobliMic's patch as is on our app and got it working. All we had to do was create a new patch file called next-auth+5.0.0-beta.19.patch and paste their patch in. Previously we couldn't get the other suggested patch for V4 working, so this is great!

We did run into a slight hiccup with the .env. The patch uses lowercase key names and our .env had uppercase. Adding a lowercase key did the trick.

@Oakwhisper Could you call out the package versions you're using for Undici and Next? I cannot get this working for the same reason as the other user.

SolidAnonDev avatar Jul 01 '24 12:07 SolidAnonDev

@SolidAnonDev We are using:

"undici": "6.19.2",
"next": "14.2.3",
"next-auth": "5.0.0-beta.19",

I did start getting that same error when attempting to use middleware for auth instead of handling it in a server component.

Oakwhisper avatar Jul 16 '24 17:07 Oakwhisper

I'm still running into the errors from my last comment and got no ideas left to try. Any help is appreciated 😓

Charismara avatar Jul 31 '24 06:07 Charismara

I'm still running into the errors from my last comment and got no ideas left to try. Any help is appreciated 😓

I have not yet found a solution for this either.

This is a bit ridiculous that we can't get simple support for a corporate proxy. The community came up with a minimal solution for V4, they said it's "not a priority" back then, and still won't do anything about it now.

SolidAnonDev avatar Aug 07 '24 21:08 SolidAnonDev

I agree this seems actually quite limiting! Imagine all the corps that could use nextjs/next-auth if they would allow this natively 👍

ajhous44 avatar Aug 09 '24 20:08 ajhous44

This issue was marked with the help needed label by a maintainer.

The issue might require some digging, so it is recommended to have some experience with the project.

Have a look at the Contributing Guide first.

This will help you set up your development environment to get started. When you are ready, open a PR, and link back to this issue in the form of adding Fixes #1234 to the PR description, where 1234 is the issue number. This will auto-close the issue when the PR gets merged, making it easier for us to keep track of what has been fixed.

Please make sure that - if applicable - you add tests for the changes you make.

If you have any questions, feel free to ask in the comments below or the PR. Generally, you don't need to @mention anyone directly, as we will get notified anyway and will respond as soon as we can)

[!NOTE]
There is no need to ask for permission "can I work on this?" Please, go ahead if there is no linked PR :slightly_smiling_face:

github-actions[bot] avatar Aug 25 '24 07:08 github-actions[bot]

We're happy to review a PR to get rid of the patch 😊 due to the complexity of corporate proxy setup - as mentioned in the PR description by @SolidAnonDev, us maintainers also haven't really found the time to implement & test it thoroughly. This is where we can really use some help from the community! 🙏

ThangHuuVu avatar Aug 25 '24 07:08 ThangHuuVu

@ThangHuuVu If it means anything, as others have echoed, the above patch worked for me.

ajhous44 avatar Aug 25 '24 20:08 ajhous44

@ajhous44

Not sure what version of next you're using, but I was unable to get it working due to the previously documented errors but now see this with the next@latest

Module build failed: UnhandledSchemeError: Reading from "node:console" is not handled by plugins (Unhandled scheme).
Webpack supports "data:" and "file:" URIs by default.
You may need an additional plugin to handle "node:" URIs.
Import trace for requested module:
node:console
./node_modules/undici/lib/mock/pending-interceptors-formatter.js
./node_modules/undici/lib/mock/mock-agent.js
./node_modules/undici/index.js
./node_modules/@auth/core/providers/microsoft-entra-id.js
./node_modules/next-auth/providers/microsoft-entra-id.js
./src/lib/auth.ts

This kind of thing with Undici is not something I know how to resolve or diagnose unfortunately.

SolidAnonDev avatar Aug 28 '24 18:08 SolidAnonDev

When I tried to reproduce the issue in callback.ts of @auth/core, I encountered errors when importing undici or node-related modules. Shouldn't we attempt to implement the proxy using only the fetch API, or be able to internally import node modules? Additionally, in the patch provided above, the init (RequestInit) object doesn't contain a dispatcher property.

Zamoca42 avatar Aug 29 '24 08:08 Zamoca42

@SolidAnonDev

Module build failed: UnhandledSchemeError: Reading from "node:console" is not handled by plugins (Unhandled scheme).

I discovered that the UnhandledSchemeError occurs when using node modules and auth.js middleware simultaneously.

Next.js supports node runtime, but when auth is added to middleware, it runs in full edge runtime, restricting the use of some node modules.

Reference: https://authjs.dev/guides/edge-compatibility#middleware

For middleware-related issues, I referred to this discussion: https://github.com/vercel/next.js/discussions/62985

Zamoca42 avatar Aug 31 '24 11:08 Zamoca42

Since I opened this issue I went ahead and just implemented auth myself utilizing a similar approach to next-auth, since we only need entra Id as our login provider, it was simple enough to do, and was pretty eye opening into how that all works. Undici never touches the middleware, and there is no code in my middleware now that can't run in the edge runtime.

That said, it made me realize because the middleware runs in the edge runtime like @Zamoca42 mentioned, it required some different libraries in order to get my session or token and run some code in the middleware pulling the auth session from cookies.

This made me think that it's probably possible to do away with next-auth's middleware entirely for this use case assuming it's as simple as getting the desired cookies using __Secure-next-auth.session-token.n in prod or just next-auth.session-token.n in dev (you'd have to have logic for re-assembling from chunks), and decode using jose which will run in the edge runtime and your NEXTAUTH_SECRET environment variable. I'm not sure what all next-auth middleware is doing so I have no idea if this will work. I also am making the assumption that the auth secret the user specifies as required by next-auth is what's used to encode these jwt's, so I'm making the assumption that you could circumvent next-auth to get that cookie and decode it with your secret.

That way the patch above could be used to proxy the requests utilizing undici, but the middleware could be handled in house, so you'd have to reassemble the cookie from chunks, decode with jose, and then provide it to your middleware function. If the auth() middleware from next-auth was facilitating CSRF tokens, I suppose you could also build some logic in there to set a CSRF token of your choosing if not present with the name next-auth expects, like __Host-next-auth.csrf-token..

I have not tried this approach so I have no idea if this will work, but if I get around to it, I will.

SolidAnonDev avatar Sep 24 '24 20:09 SolidAnonDev

Hey @SolidAnonDev, did you manage to work around the UnhandledSchemeError?

I am currently facing the same issue.

Thx!

mclbdn avatar Sep 30 '24 15:09 mclbdn

Just for anyone who got up to this point and is currently dealing with the UnhandledSchemeError, I recommend to NOT USE in middleware auth exported from auth.ts file.

Just use regular Next.js middleware and then check for token using getToken:

import { getToken } from 'next-auth/jwt'

export async function middleware(request: NextRequest) {
  const session = await getToken({
    req: request,
    secret: process.env.NEXTAUTH_SECRET,
  });

Now, you have access to it and you can check if user is authenticated and redirect them etc.

mclbdn avatar Oct 03 '24 13:10 mclbdn

Does anyone manage to make Microsoft Entra ID work with a proxy?

I've migrated from v4 to v5 based on their documentation which tells that corporate proxy is supported - https://authjs.dev/guides/corporate-proxy

But using the exact code with Microsoft Entra seems to do nothing.

Does the provider itself needs to implement something in order for this proxy to work?

shahargl avatar Nov 23 '24 19:11 shahargl

so eventually I've made it work without patching the library.

there were two different issues:

  1. Microsoft Entra (azure ad) implementing its own customFetch so passing customFetch of your own didn't override the internal customFetch - https://github.com/nextauthjs/next-auth/blob/cae450e7ffc9d18ed290dc0bcfda306a42e7a3bc/packages/core/src/providers/microsoft-entra-id.ts#L142
  2. Microsoft Entra implement profile using fetch instead of customFetch - https://github.com/nextauthjs/next-auth/blob/cae450e7ffc9d18ed290dc0bcfda306a42e7a3bc/packages/core/src/providers/microsoft-entra-id.ts#L117

The solution was to first initialize the provider and then overriding its functions.

My working code:

// auth.ts
const proxyUrl =
  process.env.HTTP_PROXY ||
  process.env.HTTPS_PROXY ||
  process.env.http_proxy ||
  process.env.https_proxy;

import { ProxyAgent, fetch as undici } from "undici";

// init proxyFetch function that will be used to route requests through proxy
function proxyFetch(
  ...args: Parameters<typeof fetch>
): ReturnType<typeof fetch> {
  console.log(
    "Proxy called for URL:",
    args[0] instanceof Request ? args[0].url : args[0]
  );
  const dispatcher = new ProxyAgent(proxyUrl!);

  if (args[0] instanceof Request) {
    const request = args[0];
    // @ts-expect-error `undici` has a `duplex` option
    return undici(request.url, {
      ...args[1],
      method: request.method,
      headers: request.headers as HeadersInit,
      body: request.body,
      dispatcher,
    });
  }

  // @ts-expect-error `undici` has a `duplex` option
  return undici(args[0], { ...(args[1] || {}), dispatcher });
}

/**
 * Creates a Microsoft Entra ID provider configuration and overrides the customFetch.
 *
 * this is a workaround to override the customFetch symbol in the provider
 * because in Microsoft entra it already has a customFetch symbol and we need to override it.
 */
export const createAzureADProvider = () => {
  if (!proxyUrl) {
    console.log("Proxy is not enabled");
  } else {
    console.log("Proxy is enabled:", proxyUrl);
  }

  // Step 1: Create the base provider
  const baseConfig = {
    clientId: process.env.KEEP_AZUREAD_CLIENT_ID!,
    clientSecret: process.env.KEEP_AZUREAD_CLIENT_SECRET!,
    issuer: `https://login.microsoftonline.com/${process.env
      .KEEP_AZUREAD_TENANT_ID!}/v2.0`,
    authorization: {
      params: {
        scope: `api://${process.env
          .KEEP_AZUREAD_CLIENT_ID!}/default openid profile email`,
      },
    },
    client: {
      token_endpoint_auth_method: "client_secret_post",
    },
  };

  const provider = MicrosoftEntraID(baseConfig);
  // if not proxyUrl, return the provider
  if (!proxyUrl) return provider;

  // Step 2: Override the `customFetch` symbol in the provider
  provider[customFetch] = async (...args: Parameters<typeof fetch>) => {
    const url = new URL(args[0] instanceof Request ? args[0].url : args[0]);
    console.log("Custom Fetch Intercepted:", url.toString());

    // Handle `.well-known/openid-configuration` logic
    if (url.pathname.endsWith(".well-known/openid-configuration")) {
      console.log("Intercepting .well-known/openid-configuration");
      const response = await proxyFetch(...args);
      const json = await response.clone().json();
      const tenantRe = /microsoftonline\.com\/(\w+)\/v2\.0/;
      const tenantId = baseConfig.issuer?.match(tenantRe)?.[1] ?? "common";
      const issuer = json.issuer.replace("{tenantid}", tenantId);
      console.log("Modified issuer:", issuer);
      return Response.json({ ...json, issuer });
    }

    // Fallback for all other requests
    return proxyFetch(...args);
  };

  // Step 3: override profile since it use fetch without customFetch
  provider.profile = async (profile, tokens) => {
    const profilePhotoSize = 48; // Default or custom size
    console.log("Fetching profile photo via proxy");

    const response = await proxyFetch(
      `https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`,
      { headers: { Authorization: `Bearer ${tokens.access_token}` } }
    );

    let image: string | null = null;
    if (response.ok && typeof Buffer !== "undefined") {
      try {
        const pictureBuffer = await response.arrayBuffer();
        const pictureBase64 = Buffer.from(pictureBuffer).toString("base64");
        image = `data:image/jpeg;base64,${pictureBase64}`;
      } catch (error) {
        console.error("Error processing profile photo:", error);
      }
    }

    // Ensure the returned object matches the User interface
    return {
      id: profile.sub,
      name: profile.name,
      email: profile.email,
      image: image ?? null,
      accessToken: tokens.access_token ?? "", 
    };
  };

  return provider;
};

and then:

[AuthType.AZUREAD]: [createAzureADProvider()],

shahargl avatar Nov 25 '24 11:11 shahargl

@shahargl Using your non-patched version, what is [AuthType.AZUREAD]: [createAzureADProvider()], referring to? Where would this go?

ajhous44 avatar Jan 24 '25 20:01 ajhous44

@ajhous44 on the authConfig, you can see how I use it here https://github.com/keephq/keep/blob/main/keep-ui/auth.config.ts#L160 and here https://github.com/keephq/keep/blob/main/keep-ui/auth.ts#L40

shahargl avatar Jan 24 '25 20:01 shahargl

That seems complex for my use case. I'm trying the patched version too from above and unable to get it working. Kinda painful. Seems some of your patch was implement in latest versions?

ajhous44 avatar Jan 24 '25 20:01 ajhous44

I am trying barebones implementation from their docs. Which, fails right off that bat with Entra ID. Ironically. Even without corp proxy stuff.

But @shahargl the patch is still not working for me. @SolidAnonDev I feel like this should be corrected without having us need to do wonky work arounds, especially for a provider as big in the corp world as Azure.

ajhous44 avatar Jan 24 '25 22:01 ajhous44

@ThangHuuVu

ajhous44 avatar Jan 24 '25 23:01 ajhous44

I solve the issue by using a proxy tool with TUN mode, e.g. https://github.com/clash-verge-rev/clash-verge-rev

Cygra avatar Feb 13 '25 06:02 Cygra

I know this is a closed issue, but wanted to add context in case more poor souls wind up here.

Huge shoutout to @Charismara for the patch-package fix. For my specific use-case needed a custom provider, so there was one additional file I needed to modify in the @auth/core package, so I ended up with this diff:

diff --git a/node_modules/@auth/core/lib/actions/callback/oauth/callback.js b/node_modules/@auth/core/lib/actions/callback/oauth/callback.js
index bebeddb..c8a2128 100644
--- a/node_modules/@auth/core/lib/actions/callback/oauth/callback.js
+++ b/node_modules/@auth/core/lib/actions/callback/oauth/callback.js
@@ -1,9 +1,10 @@
-import * as checks from "./checks.js";
+import { decodeJwt } from "jose";
 import * as o from "oauth4webapi";
+import { ProxyAgent } from "undici";
 import { OAuthCallbackError, OAuthProfileParseError, } from "../../../../errors.js";
-import { isOIDCProvider } from "../../../utils/providers.js";
 import { conformInternal, customFetch } from "../../../symbols.js";
-import { decodeJwt } from "jose";
+import { isOIDCProvider } from "../../../utils/providers.js";
+import * as checks from "./checks.js";
 function formUrlEncode(token) {
     return encodeURIComponent(token).replace(/%20/g, "+");
 }
@@ -115,6 +116,10 @@ export async function handleOAuth(params, cookies, options) {
             if (!provider.checks.includes("pkce")) {
                 args[1].body.delete("code_verifier");
             }
+            if(process.env.http_proxy) { 
+                args[1].dispatcher = new ProxyAgent(process.env.http_proxy);
+            }
             return (provider[customFetch] ?? fetch)(...args);
         },
     });
@@ -149,7 +154,14 @@ export async function handleOAuth(params, cookies, options) {
                     const tenantId = as.issuer?.match(tenantRe)?.[1] ?? "common";
                     const issuer = new URL(as.issuer.replace(tenantId, tid));
                     const discoveryResponse = await o.discoveryRequest(issuer, {
-                        [o.customFetch]: provider[customFetch],
+                        [o.customFetch]: (...args) => {
+                            if (process.env.http_proxy) {
+                                const proxy = new ProxyAgent(process.env.http_proxy);
+                                args[1].dispatcher = proxy;
+                            }
+                            return fetch(...args);
+                        },
                     });
                     as = await o.processDiscoveryResponse(issuer, discoveryResponse);
                 }
@@ -177,7 +189,14 @@ export async function handleOAuth(params, cookies, options) {
         }
         if (provider.idToken === false) {
             const userinfoResponse = await o.userInfoRequest(as, client, processedCodeResponse.access_token, {
-                [o.customFetch]: provider[customFetch],
+                [o.customFetch]:  (...args) => {
+                    if (process.env.http_proxy) {
+                        const proxy = new ProxyAgent(process.env.http_proxy);
+                        args[1].dispatcher = proxy;
+                    }
+                    return fetch(...args);
+                },
                 // TODO: move away from allowing insecure HTTP requests
                 [o.allowInsecureRequests]: true,
             });
@@ -192,7 +211,14 @@ export async function handleOAuth(params, cookies, options) {
         }
         else if (userinfo?.url) {
             const userinfoResponse = await o.userInfoRequest(as, client, processedCodeResponse.access_token, {
-                [o.customFetch]: provider[customFetch],
+                [o.customFetch]:  (...args) => {
+                    if (process.env.http_proxy) {
+                        const proxy = new ProxyAgent(process.env.http_proxy);
+                        args[1].dispatcher = proxy;
+                    }
+                    return fetch(...args);
+                },
                 // TODO: move away from allowing insecure HTTP requests
                 [o.allowInsecureRequests]: true,
             });
diff --git a/node_modules/@auth/core/lib/actions/signin/authorization-url.js b/node_modules/@auth/core/lib/actions/signin/authorization-url.js
index d0fa524..8c1f79a 100644
--- a/node_modules/@auth/core/lib/actions/signin/authorization-url.js
+++ b/node_modules/@auth/core/lib/actions/signin/authorization-url.js
@@ -1,6 +1,6 @@
-import * as checks from "../callback/oauth/checks.js";
 import * as o from "oauth4webapi";
-import { customFetch } from "../../symbols.js";
+import { ProxyAgent } from "undici";
+import * as checks from "../callback/oauth/checks.js";
 /**
  * Generates an authorization/request token URL.
  *
@@ -16,9 +16,16 @@ export async function getAuthorizationUrl(query, options) {
         // We check this in assert.ts
         const issuer = new URL(provider.issuer);
         const discoveryResponse = await o.discoveryRequest(issuer, {
-            [o.customFetch]: provider[customFetch],
             // TODO: move away from allowing insecure HTTP requests
             [o.allowInsecureRequests]: true,
+            [o.customFetch]: (...args) => {
+                if(process.env.http_proxy) {
+                    const proxy = new ProxyAgent(process.env.http_proxy);
+                    args[1].dispatcher = proxy;
+                }
+                return fetch(...args);
+            }
         });
         const as = await o
             .processDiscoveryResponse(issuer, discoveryResponse)
diff --git a/node_modules/@auth/core/lib/utils/providers.js b/node_modules/@auth/core/lib/utils/providers.js
index 22c71e8..e4f691a 100644
--- a/node_modules/@auth/core/lib/utils/providers.js
+++ b/node_modules/@auth/core/lib/utils/providers.js
@@ -1,5 +1,6 @@
-import { merge } from "./merge.js";
+import { ProxyAgent } from "undici";
 import { customFetch } from "../symbols.js";
+import { merge } from "./merge.js";
 /**
  * Adds `signinUrl` and `callbackUrl` to each provider
  * and deep merge user-defined options.
@@ -27,6 +28,18 @@ export default function parseProviders(params) {
             // @ts-expect-error Symbols don't get merged by the `merge` function
             // so we need to do it manually.
             normalized[customFetch] ?? (normalized[customFetch] = userOptions?.[customFetch]);
+            // Set up ProxyAgent from undici if proxy URL is provided
+            if (process.env.http_proxy) {
+                try {
+                    normalized[customFetch] = async (input, init = {}) => {
+                        const agent = new ProxyAgent(process.env.http_proxy);
+                        return fetch(input, { ...init, dispatcher: agent });
+                    };
+                } catch (e) {
+                    // undici not available or import failed, fallback to default
+                }
+            }
             return normalized;
         }
         return merged;
diff --git a/node_modules/@auth/core/src/lib/actions/callback/index.ts b/node_modules/@auth/core/src/lib/actions/callback/index.ts
index d11048f..7995909 100644
--- a/node_modules/@auth/core/src/lib/actions/callback/index.ts
+++ b/node_modules/@auth/core/src/lib/actions/callback/index.ts
@@ -1,17 +1,17 @@
 // TODO: Make this file smaller
 
 import {
-  AuthError,
   AccessDenied,
+  AuthError,
   CallbackRouteError,
   CredentialsSignin,
   InvalidProvider,
   Verification,
 } from "../../../errors.js"
+import { createHash } from "../../utils/web.js"
 import { handleLoginOrRegister } from "./handle-login.js"
 import { handleOAuth } from "./oauth/callback.js"
 import { state } from "./oauth/checks.js"
-import { createHash } from "../../utils/web.js"
 
 import type { AdapterSession } from "../../../adapters.js"
 import type {

In addition to these changes, another big issue we ran into is because of how we deployed the app. We're not deploying through vercel, and running in a docker image. I needed to manually tell the middleware to look for the secure version of the next-auth token. ie:

// middleware.ts
const token = await getToken({ req, secret, secureCookie: process.env.NODE_ENV === 'production' });
//...

j-s-hunsicker avatar May 15 '25 15:05 j-s-hunsicker