Repository: auth0/nextjs-auth0
Pull request #17 (2019-10-09 12:01:57)

Added logic to require authentication in an API
### Description

This PR adds an additional handler which makes it easy to require authentication on an API route.


Patch:
diff --git a/README.md b/README.md
index 6d89544d..898b91c9 100644
--- a/README.md
+++ b/README.md
@@ -65,7 +65,6 @@ export default useAuth0({
   }
 });
 ```
-Add 'http://localhost:3000/api/callback' to your list of Allowed Callback URLs in your auth0 account.
 
 ### Login
 
diff --git a/src/handlers/index.ts b/src/handlers/index.ts
index 04813d5a..e70aadcd 100644
--- a/src/handlers/index.ts
+++ b/src/handlers/index.ts
@@ -3,11 +3,13 @@ import LogoutHandler from './logout';
 import CallbackHandler from './callback';
 import ProfileHandler from './profile';
 import SessionHandler from './session';
+import RequireAuthentication from './require-authentication';
 
 export default {
   CallbackHandler,
   LoginHandler,
   LogoutHandler,
   ProfileHandler,
-  SessionHandler
+  SessionHandler,
+  RequireAuthentication
 };
diff --git a/src/handlers/logout.ts b/src/handlers/logout.ts
index 5f2506c8..262c327d 100644
--- a/src/handlers/logout.ts
+++ b/src/handlers/logout.ts
@@ -6,9 +6,9 @@ import CookieSessionStoreSettings from '../session/cookie-store/settings';
 
 function createLogoutUrl(settings: IAuth0Settings): string {
   return (
-    `https://${settings.domain}/v2/logout?` +
-    `client_id=${settings.clientId}` +
-    `&returnTo=${encodeURIComponent(settings.postLogoutRedirectUri)}`
+    `https://${settings.domain}/v2/logout?`
+    + `client_id=${settings.clientId}`
+    + `&returnTo=${encodeURIComponent(settings.postLogoutRedirectUri)}`
   );
 }
 
diff --git a/src/handlers/profile.ts b/src/handlers/profile.ts
index a4f4e66b..bb3585b3 100644
--- a/src/handlers/profile.ts
+++ b/src/handlers/profile.ts
@@ -14,7 +14,10 @@ export default function profileHandler(sessionStore: ISessionStore) {
 
     const session = await sessionStore.read(req);
     if (!session || !session.user) {
-      res.status(401).json({ error: 'Not authenticated' });
+      res.status(401).json({
+        error: 'not_authenticated',
+        description: 'The user does not have an active session or is not authenticated'
+      });
       return;
     }
 
diff --git a/src/handlers/require-authentication.ts b/src/handlers/require-authentication.ts
new file mode 100644
index 00000000..e7bdb7ac
--- /dev/null
+++ b/src/handlers/require-authentication.ts
@@ -0,0 +1,30 @@
+import { NextApiResponse, NextApiRequest } from 'next';
+
+import { ISessionStore } from '../session/store';
+
+export interface IApiRoute {
+  (req: NextApiRequest, res: NextApiResponse): Promise<void>;
+}
+
+export default function requireAuthentication(sessionStore: ISessionStore) {
+  return (apiRoute: IApiRoute): IApiRoute => async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
+    if (!req) {
+      throw new Error('Request is not available');
+    }
+
+    if (!res) {
+      throw new Error('Response is not available');
+    }
+
+    const session = await sessionStore.read(req);
+    if (!session || !session.user) {
+      res.status(401).json({
+        error: 'not_authenticated',
+        description: 'The user does not have an active session or is not authenticated'
+      });
+      return;
+    }
+
+    await apiRoute(req, res);
+  };
+}
diff --git a/src/instance.node.ts b/src/instance.node.ts
index dd8ab17e..234e5f0f 100644
--- a/src/instance.node.ts
+++ b/src/instance.node.ts
@@ -23,6 +23,7 @@ export default function createInstance(settings: IAuth0Settings): ISignInWithAut
     handleLogout: handlers.LogoutHandler(settings, sessionSettings),
     handleCallback: handlers.CallbackHandler(settings, clientProvider, store),
     handleProfile: handlers.ProfileHandler(store),
-    getSession: handlers.SessionHandler(store)
+    getSession: handlers.SessionHandler(store),
+    requireAuthentication: handlers.RequireAuthentication(store)
   };
 }
diff --git a/src/instance.ts b/src/instance.ts
index 28401136..1d8c7a0e 100644
--- a/src/instance.ts
+++ b/src/instance.ts
@@ -2,6 +2,7 @@ import { IncomingMessage, ServerResponse } from 'http';
 import { NextApiRequest, NextApiResponse } from 'next';
 
 import { ISession } from './session/session';
+import { IApiRoute } from './handlers/require-authentication';
 import { CallbackOptions } from './handlers/callback-options';
 
 export interface ISignInWithAuth0 {
@@ -26,7 +27,12 @@ export interface ISignInWithAuth0 {
   handleProfile: (req: NextApiRequest, res: NextApiResponse) => Promise<void>;
 
   /**
-   * Profile handler which return profile information about the user.
+   * Session handler which returns the current session
    */
   getSession: (req: IncomingMessage) => Promise<ISession | null | undefined>;
+
+  /**
+   * Handle to require authentication for an API route.
+   */
+  requireAuthentication: (apiRoute: IApiRoute) => IApiRoute;
 }
diff --git a/src/session/cookie-store/index.ts b/src/session/cookie-store/index.ts
index c1c03c78..80c4bd18 100644
--- a/src/session/cookie-store/index.ts
+++ b/src/session/cookie-store/index.ts
@@ -39,9 +39,13 @@ export default class CookieSessionStore implements ISessionStore {
    * @param req HTTP request
    */
   async save(_: IncomingMessage, res: ServerResponse, session: ISession): Promise<void> {
-    const { cookieSecret, cookieName, cookiePath, cookieLifetime } = this.settings;
+    const {
+      cookieSecret, cookieName, cookiePath, cookieLifetime
+    } = this.settings;
 
-    const { idToken, accessToken, refreshToken, user, createdAt } = session;
+    const {
+      idToken, accessToken, refreshToken, user, createdAt
+    } = session;
     const persistedSession = new Session(user, createdAt);
 
     if (this.settings.storeIdToken && idToken) {


Test patch:
diff --git a/tests/handlers/profile.test.ts b/tests/handlers/profile.test.ts
index bc44f2e8..7a50ae95 100644
--- a/tests/handlers/profile.test.ts
+++ b/tests/handlers/profile.test.ts
@@ -77,7 +77,8 @@ describe('profile handler', () => {
 
       expect(statusFn).toBeCalledWith(401);
       expect(jsonFn).toBeCalledWith({
-        error: 'Not authenticated'
+        error: 'not_authenticated',
+        description: 'The user does not have an active session or is not authenticated'
       });
     });
   });
diff --git a/tests/handlers/require-authentication.test.ts b/tests/handlers/require-authentication.test.ts
new file mode 100644
index 00000000..3f17ccee
--- /dev/null
+++ b/tests/handlers/require-authentication.test.ts
@@ -0,0 +1,94 @@
+import { NextApiRequest, NextApiResponse } from 'next';
+
+import handlers from '../../src/handlers';
+import { ISession } from '../../src/session/session';
+import { ISessionStore } from '../../src/session/store';
+import getRequestResponse from '../helpers/http';
+
+describe('require authentication handle handler', () => {
+  const apiRoute = async (_: NextApiRequest, res: NextApiResponse): Promise<void> => {
+    await Promise.resolve();
+    res.json({
+      foo: 'bar'
+    });
+  };
+
+  const getStore = (session?: ISession): ISessionStore => {
+    const store: ISessionStore = {
+      read(): Promise<ISession | null | undefined> {
+        return Promise.resolve(session);
+      },
+      save(): Promise<void> {
+        return Promise.resolve();
+      }
+    };
+    return store;
+  };
+
+  describe('when the call is invalid', () => {
+    test('should throw an error if the request is null', async () => {
+      const store = getStore();
+      const requireAuthenticationHandler = handlers.RequireAuthentication(store);
+
+      const req: any = null;
+      const { res } = getRequestResponse();
+
+      return expect(requireAuthenticationHandler(apiRoute)(req, res)).rejects.toEqual(
+        new Error('Request is not available')
+      );
+    });
+
+    test('should throw an error if the response is null', async () => {
+      const store = getStore();
+      const requireAuthenticationHandler = handlers.RequireAuthentication(store);
+
+      const { req } = getRequestResponse();
+      const res: any = null;
+
+      return expect(requireAuthenticationHandler(apiRoute)(req, res)).rejects.toEqual(
+        new Error('Response is not available')
+      );
+    });
+  });
+
+  describe('when signed in', () => {
+    const store = getStore({
+      user: {
+        sub: '123'
+      },
+      idToken: 'my-id-token',
+      accessToken: 'my-access-token',
+      refreshToken: 'my-refresh-token',
+      createdAt: Date.now()
+    });
+
+    const { req, res, jsonFn } = getRequestResponse();
+
+    test('should execute the API route', async () => {
+      const requireAuthenticationHandler = handlers.RequireAuthentication(store);
+      await requireAuthenticationHandler(apiRoute)(req, res);
+
+      expect(jsonFn).toBeCalledWith({
+        foo: 'bar'
+      });
+    });
+  });
+
+  describe('when not signed in', () => {
+    const store = getStore();
+    const {
+      req, res, jsonFn, statusFn
+    } = getRequestResponse();
+
+    test('should return not authenticated', async () => {
+      const requireAuthenticationHandler = handlers.RequireAuthentication(store);
+      await requireAuthenticationHandler(apiRoute)(req, res);
+
+      expect(statusFn).toBeCalledWith(401);
+      expect(jsonFn).toBeCalledWith({
+        error: 'not_authenticated',
+        description: 'The user does not have an active session or is not authenticated'
+      });
+    });
+  });
+});