Repository: auth0/nextjs-auth0 (ts)

Issue:
Protect API routes with withMiddlewareAuthRequired
### Checklist

- [X] I have looked into the [README](https://github.com/auth0/nextjs-auth0#readme) and have not found a suitable solution or answer.
- [X] I have looked into the [examples](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md) and have not found a suitable solution or answer.
- [X] I have looked into the [API documentation](https://auth0.github.io/nextjs-auth0/) and have not found a suitable solution or answer.
- [X] I have searched the [issues](https://github.com/auth0/nextjs-auth0/issues) and have not found a suitable solution or answer.
- [X] I have searched the [Auth0 Community](https://community.auth0.com/c/sdks/5) forums and have not found a suitable solution or answer.
- [X] I agree to the terms within the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md).

### Describe the problem you'd like to have solved

I have a NextJS server that has both page routes and API endpoints under `/api`. I want to use `withMiddlewareAuthRequired` to validate session token for all page routes and API endpoints that are not ignored by `config.matcher`. Once `@auth0/nextjs-auth0` did its magic, I want to execute custom code to check for the presence of specific roles and custom claims in the token. If these elements are not present, depending on the entry point (page route or API endpoint) I'd either redirect the user to Auth0 login or return a 401.

I can already craft my custom logic for the middleware by providing `withMiddlewareAuthRequired` a function. However right now when using `withMiddlewareAuthRequired` if I attempt to access a protected API route without any authentication information, the client gets redirected to the Auth0 login. 

### Describe the ideal solution

IMO redirection is the desired behaviour when accessing page routes however when you access any protected endpoint under `/api` it should return a 401 without redirecting the client.

### Alternatives and current workarounds

I don't have any since the way the redirect is handled is outside of the user control. One way would be to ignore all API routes but it defeats the purpose of middleware IMO. I'd have to craft the token validation myself and apply it manually to all protected API endpoints which is error prone.

### Additional context

next@12.3.2
@auth0/nextjs-auth0@2.0.0-beta.1
node@16.15.1

Pull request description:
WithMiddlewareAuthRequired should return 401 for /api routes
### 📋 Changes

`/api` routes protected by `withMiddlewareAuthRequired` should respond with a 401 for unauthenticated routes (currently it just expects UI pages so only responds with temporary redirects to the login page)

Since middleware can't respond with a body - the recommended way to do this in Next.js middleware is to create a stub api route and rewrite to that

### 📎 References

https://nextjs.org/docs/messages/returning-response-body-in-middleware
fixes #903

### 🎯 Testing

```bash
$ npm run start:kitchen-sink
$ curl -I http://localhost:3000/api/hello-world-mw
HTTP/1.1 401 Unauthorized
```

Patch:
diff --git a/examples/kitchen-sink-example/middleware.ts b/examples/kitchen-sink-example/middleware.ts
index 71300945..dd66b96d 100644
--- a/examples/kitchen-sink-example/middleware.ts
+++ b/examples/kitchen-sink-example/middleware.ts
@@ -3,5 +3,5 @@ import { withMiddlewareAuthRequired } from '@auth0/nextjs-auth0/middleware';
 export default withMiddlewareAuthRequired();
 
 export const config = {
-  matcher: '/profile-mw'
+  matcher: ['/profile-mw', '/api/hello-world-mw']
 };
diff --git a/examples/kitchen-sink-example/pages/api/hello-world-mw.ts b/examples/kitchen-sink-example/pages/api/hello-world-mw.ts
new file mode 100644
index 00000000..77c77f47
--- /dev/null
+++ b/examples/kitchen-sink-example/pages/api/hello-world-mw.ts
@@ -0,0 +1,3 @@
+export default async function helloWorld(req, res) {
+  res.status(200).json({ text: 'Hello World!' });
+}
diff --git a/src/config.ts b/src/config.ts
index 3df84509..ce667bc5 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -308,6 +308,7 @@ export interface NextConfig extends Pick<BaseConfig, 'identityClaimFilter'> {
   routes: {
     callback: string;
     login: string;
+    unauthorized: string;
   };
 }
 
@@ -522,7 +523,8 @@ export const getConfig = (params: ConfigParameters = {}): { baseConfig: BaseConf
   const nextConfig = {
     routes: {
       ...baseConfig.routes,
-      login: baseParams.routes?.login || getLoginUrl()
+      login: baseParams.routes?.login || getLoginUrl(),
+      unauthorized: baseParams.routes?.unauthorized || '/api/auth/401'
     },
     identityClaimFilter: baseConfig.identityClaimFilter,
     organization: organization || AUTH0_ORGANIZATION
diff --git a/src/handlers/auth.ts b/src/handlers/auth.ts
index 61c0d3c6..8e62c51a 100644
--- a/src/handlers/auth.ts
+++ b/src/handlers/auth.ts
@@ -81,12 +81,13 @@ type ErrorHandlers = {
  * export default handleAuth();
  * ```
  *
- * This will create 4 handlers for the following urls:
+ * This will create 5 handlers for the following urls:
  *
  * - `/api/auth/login`: log the user in to your app by redirecting them to your identity provider.
  * - `/api/auth/callback`: The page that your identity provider will redirect the user back to on login.
  * - `/api/auth/logout`: log the user out of your app.
- * - `/api/auth/me`: View the user profile JSON (used by the {@link UseUser} hook)
+ * - `/api/auth/me`: View the user profile JSON (used by the {@link UseUser} hook).
+ * - `/api/auth/unauthorized`: Returns a 401 for use by {@link WithMiddlewareAuthRequired} when protecting API routes.
  *
  * @category Server
  */
@@ -123,6 +124,19 @@ const defaultOnError: OnError = (_req, res, error) => {
   res.status(error.status || 500).end();
 };
 
+/**
+ * This is a handler for use by {@link WithMiddlewareAuthRequired} when protecting an API route.
+ * Middleware can't return a response body, so an unauthorized request for an API route
+ * needs to rewrite to this handler.
+ * @ignore
+ */
+const unauthorized: NextApiHandler = (_req, res) => {
+  res.status(401).json({
+    error: 'not_authenticated',
+    description: 'The user does not have an active session or is not authenticated'
+  });
+};
+
 /**
  * @ignore
  */
@@ -143,6 +157,7 @@ export default function handlerFactory({
       logout: handleLogout,
       callback: handleCallback,
       me: (handlers as ApiHandlers).profile || handleProfile,
+      401: unauthorized,
       ...handlers
     };
     return async (req, res): Promise<void> => {
diff --git a/src/helpers/with-middleware-auth-required.ts b/src/helpers/with-middleware-auth-required.ts
index faa03928..0e68cbee 100644
--- a/src/helpers/with-middleware-auth-required.ts
+++ b/src/helpers/with-middleware-auth-required.ts
@@ -49,14 +49,14 @@ export type WithMiddlewareAuthRequired = (middleware?: NextMiddleware) => NextMi
  * @ignore
  */
 export default function withMiddlewareAuthRequiredFactory(
-  { login, callback }: { login: string; callback: string },
+  { login, callback, unauthorized }: { login: string; callback: string; unauthorized: string },
   getSessionCache: () => SessionCache<NextRequest, NextResponse>
 ): WithMiddlewareAuthRequired {
   return function withMiddlewareAuthRequired(middleware?): NextMiddleware {
     return async function wrappedMiddleware(...args) {
       const [req] = args;
       const { pathname, origin } = req.nextUrl;
-      const ignorePaths = [login, callback, '/_next', '/favicon.ico'];
+      const ignorePaths = [login, callback, unauthorized, '/_next', '/favicon.ico'];
       if (ignorePaths.some((p) => pathname.startsWith(p))) {
         return;
       }
@@ -66,6 +66,9 @@ export default function withMiddlewareAuthRequiredFactory(
       const authRes = NextResponse.next();
       const session = await sessionCache.get(req, authRes);
       if (!session?.user) {
+        if (pathname.startsWith('/api')) {
+          return NextResponse.rewrite(new URL(unauthorized, origin), { status: 401 });
+        }
         return NextResponse.redirect(
           new URL(`${login}?returnTo=${encodeURIComponent(req.nextUrl.toString())}`, origin)
         );


Test patch:
diff --git a/tests/config.test.ts b/tests/config.test.ts
index 4e2ecf07..5654494f 100644
--- a/tests/config.test.ts
+++ b/tests/config.test.ts
@@ -92,7 +92,8 @@ describe('config params', () => {
       routes: {
         login: '/api/auth/login',
         callback: '/api/auth/callback',
-        postLogoutRedirect: ''
+        postLogoutRedirect: '',
+        unauthorized: '/api/auth/401'
       },
       organization: undefined
     });
diff --git a/tests/handlers/auth.test.ts b/tests/handlers/auth.test.ts
index 4b56d218..b867fcd2 100644
--- a/tests/handlers/auth.test.ts
+++ b/tests/handlers/auth.test.ts
@@ -44,6 +44,12 @@ describe('auth handler', () => {
     global.handleAuth = initAuth0(withoutApi).handleAuth;
     await expect(get(baseUrl, '/api/auth/__proto__')).rejects.toThrow('Not Found');
   });
+
+  test('return unauthorized for /401 route', async () => {
+    const baseUrl = await setup(withoutApi);
+    global.handleAuth = initAuth0(withoutApi).handleAuth;
+    await expect(get(baseUrl, '/api/auth/401')).rejects.toThrow('Unauthorized');
+  });
 });
 
 describe('custom error handler', () => {
diff --git a/tests/helpers/with-middleware-auth-required.test.ts b/tests/helpers/with-middleware-auth-required.test.ts
index 507b2cac..b9f6672e 100644
--- a/tests/helpers/with-middleware-auth-required.test.ts
+++ b/tests/helpers/with-middleware-auth-required.test.ts
@@ -55,6 +55,21 @@ describe('with-middleware-auth-required', () => {
     expect(redirect.searchParams.get('returnTo')).toEqual('http://example.com/');
   });
 
+  test('require auth on anonymous requests to api routes', async () => {
+    const res = await setup({ url: 'http://example.com/api/foo' });
+    expect(res.status).toEqual(401);
+    expect(res.headers.get('x-middleware-rewrite')).toEqual('http://example.com/api/auth/401');
+  });
+
+  test('require auth on anonymous requests to api routes with custom 401', async () => {
+    const res = await setup({
+      url: 'http://example.com/api/foo',
+      config: { ...withoutApi, routes: { unauthorized: '/api/foo-401' } }
+    });
+    expect(res.status).toEqual(401);
+    expect(res.headers.get('x-middleware-rewrite')).toEqual('http://example.com/api/foo-401');
+  });
+
   test('return to previous url', async () => {
     const res = await setup({ url: 'http://example.com/foo/bar?baz=hello' });
     const redirect = new URL(res.headers.get('location') as string);
diff --git a/tests/session/session.test.ts b/tests/session/session.test.ts
index 49121cca..527c9a34 100644
--- a/tests/session/session.test.ts
+++ b/tests/session/session.test.ts
@@ -3,6 +3,8 @@ import { fromJson, fromTokenSet } from '../../src/session';
 import { makeIdToken } from '../auth0-session/fixtures/cert';
 import { Session } from '../../src';
 
+const routes = { login: '', callback: '', postLogoutRedirect: '', unauthorized: '' };
+
 describe('session', () => {
   test('should construct a session with a user', async () => {
     expect(new Session({ foo: 'bar' }).user).toEqual({ foo: 'bar' });
@@ -13,7 +15,7 @@ describe('session', () => {
       expect(
         fromTokenSet(new TokenSet({ id_token: await makeIdToken({ foo: 'bar', bax: 'qux' }) }), {
           identityClaimFilter: ['baz'],
-          routes: { login: '', callback: '', postLogoutRedirect: '' }
+          routes
         }).user
       ).toEqual({
         aud: '__test_client_id__',
@@ -32,7 +34,7 @@ describe('session', () => {
       expect(
         fromTokenSet(new TokenSet({ id_token: await makeIdToken({ foo: 'bar' }) }), {
           identityClaimFilter: ['baz'],
-          routes: { login: '', callback: '', postLogoutRedirect: '' }
+          routes
         }).idToken
       ).toBeUndefined();
     });
@@ -49,7 +51,7 @@ describe('session', () => {
             cookie: { transient: false, httpOnly: false, sameSite: 'lax' }
           },
           identityClaimFilter: ['baz'],
-          routes: { login: '', callback: '', postLogoutRedirect: '' }
+          routes
         }).idToken
       ).not.toBeUndefined();
     });