Repository: auth0/nextjs-auth0 (ts)

Issue:
Wildcard Callback URL
### Describe the problem

We've upgraded from the beta to 1.0 (congrats!) and are working to get authentication set up for our multi-tenant Nextjs application. We're using the basic setup and have changed our `AUTH0_BASE_URL` to `https://*.domain.com` however, when attempting to authenticate, we're running into an issue where the `*` is being URL encoded and seemingly causing the browser to throw an error. 

Strangely, the behavior is distinct in Chrome vs Firefox. On Chrome, it fails after submission of credentials on the Universal Login page on the callback: "This site can’t be reached. Check if there is a typo in %2A.domain.com."
- URL:  `https://%2A.domain.com/api/auth/callback?code`

On Firefox, it throws an error before it can even connect to the Universal Login: "The page you are trying to view cannot be shown because an error in the data transmission was detected."
- URL:  `https://tenant.auth0.com/authorize?client_id`

We added our `https://*.domain.com/api/auth/callback` to the Application Settings Allowed Callback URL's, followed the rules listed on the [Auth0 Wildcards for Subdomains doc](https://auth0.com/docs/applications/wildcards-for-subdomains), and even tried adding a `AUTH0_COOKIE_DOMAIN` set to `.domain.com` since we noticed the `appSession` cookie gets set with the specific subdomain -- nothing seemed to work.

We have some ideas on how we might try to hack our way around this, but figured we should ask here first. Are we goofing something up? Is this a bug? 

Side question: The documentation doesn't make it super clear, but all of the [Optional Config](https://auth0.github.io/nextjs-auth0/modules/config.html) options can be added as environment variables without needing to create our own instance with `initAuth0` right? We can safely use the basic setup + option env variables to get the desired effect? I was under the impression it did since it seemed to work with `AUTH0_SESSION_ABSOLUTE_DURATION` but I can't seem to get `AUTH0_COOKIE_DOMAIN` to set `.domain.com` 

### What was the expected behavior?

Successful authentication :(

### Reproduction

Basic setup, but change your 'BASE_URL' to contain a `*`

### Environment

Version: 1.0.0
OS: Windows 10 
Chrome: 88
Firefox 85


Pull request description:
Allow setting redirect_uri at runtime on the callback handler just like it's allowed on the login handler
### Description

While there is currently a way to specify at runtime the `redirect_uri` used within the login handler (thus overriding  `process.env.AUTH0_BASE_URL`), the same cannot be done from within the callback handler, resulting in an `unauthorized_client` bad request error indicating the mismatch between both URLs.

This lack of consistency is evident in [login.ts](https://github.com/auth0/nextjs-auth0/blob/beta/src/auth0-session/handlers/login.ts#L26) where `LoginOptions.authorizationParams.redirect_uri` takes precedence over building the redirect URL with `urlJoin(config.baseURL, config.routes.callback)`, while on [callback.ts](https://github.com/auth0/nextjs-auth0/blob/beta/src/auth0-session/handlers/callback.ts#L31) there is no possibility of modification: the redirect url will always be `urlJoin(config.baseURL, config.routes.callback)`.

This PR introduces a new option on `CallbackOptions`: the optional `redirectUri`. With it, one can specify a different URL at runtime in `[...auth0].tsx` by overriding the `login` and `callback` handlers. In the following example, assume that the redirect URI is different from `AUTH0_BASE_URL`, and that its definition might depend on custom headers or other data coming from `NextApiRequest`, thus rendering the alternative of customizing `initAuth0` not viable.

```typescript
import { handleAuth, handleCallback, handleLogin } from '@auth0/nextjs-auth0';
import { NextApiRequest } from 'next';

export default handleAuth({
  async login(req, res) {
    const baseUrl = "http://messi:3000";
    const options = {
      authorizationParams: {
        redirect_uri: `${baseUrl}/api/auth/callback`
      }
    };

    try {
      await handleLogin(req, res, options);
    } catch (error) {
      res.status(error.status || 400).end(error.message);
    }
  },

  async callback(req, res) {
    const baseUrl = "http://messi:3000";
    const options = {
      redirectUri: `${baseUrl}/api/auth/callback`
    };

    try {
      await handleCallback(req, res, options);
    } catch (error) {
      res.status(error.status || 400).end(error.message);
    }
  }
});
```

### References

Other issues (#108, #154, #295) either directly or indirectly reference this limitation, mostly within the context of a Vercel deployment. We have experienced this ourselves while developing a multi-tenant nextjs auth0 app.

### Testing

I've introduced a test case to ensure `CallbackOptions.redirect_uri` can be specified at runtime.

To replicate this in a real application just set your `AUTH0_BASE_URL` environment variable to a specific host (for example `http://localhost:3000`), and then define a different URL in `authorizationParams.redirect_uri` on the login handler (such as `http://messi:3000`), which should resolve to the same nextjs app. After logging in on auth0 and being sent back to the nextjs app to handle the oauth callback, it'll consistently fail with a bad request `unauthorized_client (The redirect URI is wrong. You sent http://localhost:3000, and we expected http://messi:3000)`. Applying this PR and utilizing `CallbackOptions.redirectUri` as shown before solves the problem.

Patch:
diff --git a/src/auth0-session/handlers/callback.ts b/src/auth0-session/handlers/callback.ts
index 832e875d..8328afbd 100644
--- a/src/auth0-session/handlers/callback.ts
+++ b/src/auth0-session/handlers/callback.ts
@@ -15,6 +15,8 @@ export type AfterCallback = (req: any, res: any, session: any, state: Record<str
 
 export type CallbackOptions = {
   afterCallback?: AfterCallback;
+
+  redirectUri?: string;
 };
 
 export type HandleCallback = (req: IncomingMessage, res: ServerResponse, options?: CallbackOptions) => Promise<void>;
@@ -27,8 +29,7 @@ export default function callbackHandlerFactory(
 ): HandleCallback {
   return async (req, res, options) => {
     const client = await getClient();
-
-    const redirectUri = getRedirectUri(config);
+    const redirectUri = options?.redirectUri || getRedirectUri(config);
 
     let expectedState;
     let tokenSet;
diff --git a/src/handlers/callback.ts b/src/handlers/callback.ts
index 5acf924c..fa963f76 100644
--- a/src/handlers/callback.ts
+++ b/src/handlers/callback.ts
@@ -70,6 +70,8 @@ export type AfterCallback = (
  */
 export type CallbackOptions = {
   afterCallback?: AfterCallback;
+
+  redirectUri?: string;
 };
 
 /**


Test patch:
diff --git a/tests/auth0-session/fixtures/server.ts b/tests/auth0-session/fixtures/server.ts
index 63d593cc..c38d904d 100644
--- a/tests/auth0-session/fixtures/server.ts
+++ b/tests/auth0-session/fixtures/server.ts
@@ -17,7 +17,8 @@ import {
   logoutHandler,
   callbackHandler,
   LoginOptions,
-  LogoutOptions
+  LogoutOptions,
+  CallbackOptions
 } from '../../../src/auth0-session';
 import wellKnown from './well-known.json';
 import { jwks } from './cert';
@@ -52,7 +53,7 @@ class TestSessionCache implements SessionCache {
 type Handlers = {
   handleLogin: (req: IncomingMessage, res: ServerResponse, opts?: LoginOptions) => Promise<void>;
   handleLogout: (req: IncomingMessage, res: ServerResponse, opts?: LogoutOptions) => Promise<void>;
-  handleCallback: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
+  handleCallback: (req: IncomingMessage, res: ServerResponse, opts?: CallbackOptions) => Promise<void>;
   handleSession: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
 };
 
@@ -103,7 +104,7 @@ const parseJson = (req: IncomingMessage, res: ServerResponse): Promise<IncomingM
 
 const requestListener = (
   handlers: Handlers,
-  { loginOptions, logoutOptions }: { loginOptions?: LoginOptions; logoutOptions?: LogoutOptions }
+  { callbackOptions, loginOptions, logoutOptions }: { callbackOptions?: CallbackOptions; loginOptions?: LoginOptions; logoutOptions?: LogoutOptions }
 ) => async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
   const { pathname } = url.parse(req.url as string, true);
   const parsedReq = await parseJson(req, res);
@@ -115,7 +116,7 @@ const requestListener = (
       case '/logout':
         return await handlers.handleLogout(parsedReq, res, logoutOptions);
       case '/callback':
-        return await handlers.handleCallback(parsedReq, res);
+        return await handlers.handleCallback(parsedReq, res, callbackOptions);
       case '/session':
         return await handlers.handleSession(parsedReq, res);
       default:
@@ -133,12 +134,14 @@ let server: HttpServer | HttpsServer;
 export const setup = async (
   params: Omit<ConfigParameters, 'baseURL'>,
   {
+    callbackOptions,
     loginOptions,
     logoutOptions,
     customListener,
     https
   }: {
     https?: boolean;
+    callbackOptions?: CallbackOptions;
     loginOptions?: LoginOptions;
     logoutOptions?: LogoutOptions;
     customListener?: (req: IncomingMessage, res: ServerResponse) => void;
@@ -172,7 +175,7 @@ export const setup = async (
   const port = await new Promise((resolve) => server.listen(0, () => resolve((server.address() as AddressInfo).port)));
   const baseURL = `http${https ? 's' : ''}://localhost:${port}`;
 
-  listener = customListener || requestListener(createHandlers({ ...params, baseURL }), { loginOptions, logoutOptions });
+  listener = customListener || requestListener(createHandlers({ ...params, baseURL }), { callbackOptions, loginOptions, logoutOptions });
   return baseURL;
 };
 
diff --git a/tests/auth0-session/handlers/callback.test.ts b/tests/auth0-session/handlers/callback.test.ts
index e4ac69fa..c0b5a487 100644
--- a/tests/auth0-session/handlers/callback.test.ts
+++ b/tests/auth0-session/handlers/callback.test.ts
@@ -372,4 +372,22 @@ describe('callback', () => {
     expect(res.statusCode).toEqual(302);
     expect(res.headers.location).toEqual(baseURL);
   });
+
+  it('should accept custom runtime redirect over base url', async () => {
+    const redirectUri = 'http://messi:3000/api/auth/callback/runtime';
+    const baseURL = await setup(defaultConfig, { callbackOptions: { redirectUri } });
+    const state = encodeState({ foo: 'bar' });
+    const cookieJar = toSignedCookieJar( { state, nonce: '__test_nonce__' }, baseURL);
+    const { res } = await post(baseURL, '/callback', {
+      body: {
+        state: state,
+        id_token: makeIdToken()
+      },
+      cookieJar,
+      fullResponse: true
+    });
+
+    expect(res.statusCode).toEqual(302);
+    expect(res.headers.location).toEqual(baseURL);
+  });
 });