Repository: auth0/nextjs-auth0
Pull request #81 (2020-02-17 23:34:25)

Add redirectTo support
### Description
Adds support for custom redirect url at the beginning of the login transaction.

The `handleLogin` method now receives an optional `redirectTo` parameter and writes it to an `a0:redirectTo` cookie in the same way as `state` is written. Then, if the `handleCallback` method doesn't receive a `redirectTo` parameter directly, it will redirect to the value stored in the `a0:redirectTo` cookie.

#### Previously evaluated alternatives 
- Write the cookie outside of the SDK: this is not possible since the SDK sets the `Set-Cookie` header and overrides any previously set value.
- Use state to encode redirect url: this is non-trivial to do for users

### Testing
- [x] This change adds test coverage for new/changed/fixed functionality

### Checklist
- [x] I have added documentation for new/changed functionality in this PR or in auth0.com/docs
- [x] All active GitHub checks for tests, formatting, and security are passing
- [x] The correct base branch is being used, if not `master`


nicogarcia: I noticed that since the `redirectTo` value is not required if we use it in one call and then don't include it, the cookie will still be there if not expired.

Alternative solutions:
- Always set (with a default) like `a0:state`
- Clean the cookie in `handleCallback`

Patch:
diff --git a/README.md b/README.md
index 3dd180b2..49face77 100644
--- a/README.md
+++ b/README.md
@@ -135,6 +135,7 @@ You can optionally send extra parameters to Auth0 to influence the transaction,
 - Filling in the user's email address
 - Exposing information to the custom login page (eg: to show the signup tab)
 - Using a custom `state`
+- Redirecting the user to a `redirectTo` url after the transaction is finished
 
 ```js
 import auth0 from '../../utils/auth0';
@@ -148,7 +149,8 @@ export default async function login(req, res) {
         scope: 'some other scope',
         state: 'a custom state',
         foo: 'bar'
-      }
+      },
+      redirectTo: '/custom-url'
     });
   } catch (error) {
     console.error(error);
diff --git a/package-lock.json b/package-lock.json
index d6d8284e..fdd0c9dc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1736,9 +1736,9 @@
       }
     },
     "acorn": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz",
-      "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==",
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz",
+      "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==",
       "dev": true
     },
     "acorn-globals": {
@@ -1752,9 +1752,9 @@
       },
       "dependencies": {
         "acorn": {
-          "version": "6.3.0",
-          "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.3.0.tgz",
-          "integrity": "sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA==",
+          "version": "6.4.1",
+          "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz",
+          "integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==",
           "dev": true
         }
       }
@@ -7074,9 +7074,9 @@
       },
       "dependencies": {
         "acorn": {
-          "version": "5.7.3",
-          "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz",
-          "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==",
+          "version": "5.7.4",
+          "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz",
+          "integrity": "sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==",
           "dev": true
         }
       }
@@ -11412,9 +11412,9 @@
       },
       "dependencies": {
         "acorn": {
-          "version": "6.3.0",
-          "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.3.0.tgz",
-          "integrity": "sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA==",
+          "version": "6.4.1",
+          "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz",
+          "integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==",
           "dev": true
         },
         "eslint-scope": {
diff --git a/src/handlers/callback.ts b/src/handlers/callback.ts
index de166b99..900ec156 100644
--- a/src/handlers/callback.ts
+++ b/src/handlers/callback.ts
@@ -46,8 +46,8 @@ export default function callbackHandler(
     // Create the session.
     await sessionStore.save(req, res, session);
 
-    // Redirect to the homepage.
-    const redirectTo = (options && options.redirectTo) || '/';
+    // Redirect to the homepage or custom url.
+    const redirectTo = (options && options.redirectTo) || cookies['a0:redirectTo'] || '/';
     res.writeHead(302, {
       Location: redirectTo
     });
diff --git a/src/handlers/login.ts b/src/handlers/login.ts
index ee89eae2..7cc0d096 100644
--- a/src/handlers/login.ts
+++ b/src/handlers/login.ts
@@ -37,6 +37,7 @@ export interface AuthorizationParameters {
 
 export interface LoginOptions {
   authParams?: AuthorizationParameters;
+  redirectTo?: string;
 }
 
 export default function loginHandler(settings: IAuth0Settings, clientProvider: IOidcClientFactory) {
@@ -51,6 +52,7 @@ export default function loginHandler(settings: IAuth0Settings, clientProvider: I
 
     const opt = options || {};
     const { state = base64url(randomBytes(48)), ...authParams } = (opt && opt.authParams) || {};
+    const { redirectTo } = opt;
 
     // Create the authorization url.
     const client = await clientProvider();
@@ -70,6 +72,11 @@ export default function loginHandler(settings: IAuth0Settings, clientProvider: I
         name: 'a0:state',
         value: state,
         maxAge: 60 * 60
+      },
+      {
+        name: 'a0:redirectTo',
+        value: redirectTo || '/',
+        maxAge: 60 * 60
       }
     ]);
 


Test patch:
diff --git a/tests/handlers/callback.test.ts b/tests/handlers/callback.test.ts
index 5acdc0f2..5b4f9d88 100644
--- a/tests/handlers/callback.test.ts
+++ b/tests/handlers/callback.test.ts
@@ -220,7 +220,7 @@ describe('callback handler', () => {
           url: `${httpServer.getUrl()}?state=foo&code=bar`,
           followRedirect: false,
           headers: {
-            cookie: 'a0:state=foo;'
+            cookie: 'a0:state=foo;a0:redirectTo=/custom-url;'
           }
         });
 
@@ -260,6 +260,11 @@ describe('callback handler', () => {
         expect(cookie['Max-Age']).toBe('3600');
         expect(cookie.Expires).toBe(new Date(time.getTime() + 3600 * 1000).toUTCString());
       });
+
+      test('should redirect to cookie url', async () => {
+        expect(responseStatus).toBe(302);
+        expect(responseHeaders.location).toBe('/custom-url');
+      });
     });
   });
 
diff --git a/tests/handlers/login.test.ts b/tests/handlers/login.test.ts
index 2515eb64..59229d39 100644
--- a/tests/handlers/login.test.ts
+++ b/tests/handlers/login.test.ts
@@ -17,7 +17,7 @@ describe('login handler', () => {
 
   beforeEach(done => {
     discovery(withoutApi);
-    loginOptions = null;
+    loginOptions = { redirectTo: '/custom-url' };
     loginHandler = login(withoutApi, getClient(withoutApi));
     httpServer = new HttpServer((req, res) => loginHandler(req, res, loginOptions));
     httpServer.start(done);
@@ -37,6 +37,18 @@ describe('login handler', () => {
     expect(state).toBeTruthy();
   });
 
+  test('should create a redirectTo cookie', async () => {
+    const { headers } = await getAsync({
+      url: httpServer.getUrl(),
+      followRedirect: false
+    });
+
+    const state = parse(headers['set-cookie'][0]);
+    const redirectTo = parse(headers['set-cookie'][1]);
+    expect(state).toBeTruthy();
+    expect(redirectTo['a0:redirectTo']).toEqual('/custom-url');
+  });
+
   test('should redirect to the identity provider', async () => {
     const { statusCode, headers } = await getAsync({
       url: httpServer.getUrl(),