From 315f092929f1febb7a3a9be97e624b36f734a4ba Mon Sep 17 00:00:00 2001 From: adamjmcgrath Date: Tue, 15 Nov 2022 16:36:16 +0000 Subject: [PATCH] WithMiddlewareAuthRequired should return 401 for /api routes --- examples/kitchen-sink-example/middleware.ts | 2 +- .../pages/api/hello-world-mw.ts | 3 +++ src/config.ts | 4 +++- src/handlers/auth.ts | 19 +++++++++++++++++-- src/helpers/with-middleware-auth-required.ts | 7 +++++-- tests/config.test.ts | 3 ++- tests/handlers/auth.test.ts | 6 ++++++ .../with-middleware-auth-required.test.ts | 15 +++++++++++++++ tests/session/session.test.ts | 8 +++++--- 9 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 examples/kitchen-sink-example/pages/api/hello-world-mw.ts diff --git a/examples/kitchen-sink-example/middleware.ts b/examples/kitchen-sink-example/middleware.ts index 713009451..dd66b96dd 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 000000000..77c77f470 --- /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 3df845096..ce667bc59 100644 --- a/src/config.ts +++ b/src/config.ts @@ -308,6 +308,7 @@ export interface NextConfig extends Pick { 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 61c0d3c65..8e62c51a2 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 => { diff --git a/src/helpers/with-middleware-auth-required.ts b/src/helpers/with-middleware-auth-required.ts index faa039281..0e68cbee9 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 ): 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) ); diff --git a/tests/config.test.ts b/tests/config.test.ts index 4e2ecf072..5654494f2 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 4b56d218f..b867fcd2b 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 507b2cac1..b9f6672ef 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 49121cca6..527c9a34a 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(); });