diff --git a/.changeset/nextjs-protect-route-redirect-loop.md b/.changeset/nextjs-protect-route-redirect-loop.md new file mode 100644 index 000000000..415117520 --- /dev/null +++ b/.changeset/nextjs-protect-route-redirect-loop.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/nextjs': patch +--- + +`protectRoute()` in the middleware no longer produces redirect loops. Without a configured `signInUrl` it redirected unauthenticated requests to the same-origin referer, and because browsers keep the referer of the page that started the navigation across a redirect chain, a protected page whose referer was itself (for example after the session expired while browsing protected pages) bounced until `ERR_TOO_MANY_REDIRECTS`. The referer is now only used when it is a different page, and when the resolved target is the protected route itself (the sign-in page covered by the protected matcher, or `/` protected without a `signInUrl`) the middleware answers `401` with a hint instead of redirecting. The JSDoc no longer mentions a `defaultRedirect` option that never existed. diff --git a/packages/nextjs/src/server/middleware/__tests__/asgardeoMiddleware.test.ts b/packages/nextjs/src/server/middleware/__tests__/asgardeoMiddleware.test.ts new file mode 100644 index 000000000..555416f50 --- /dev/null +++ b/packages/nextjs/src/server/middleware/__tests__/asgardeoMiddleware.test.ts @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {NextRequest, NextResponse} from 'next/server'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import asgardeoMiddleware, {AsgardeoMiddlewareContext} from '../asgardeoMiddleware'; + +describe('asgardeoMiddleware protectRoute', () => { + const originalSignInUrl: string | undefined = process.env['NEXT_PUBLIC_ASGARDEO_SIGN_IN_URL']; + + const protect = async ( + url: string, + options: {headers?: Record; redirect?: string; signInUrl?: string} = {}, + ): Promise => { + const middleware: (request: NextRequest) => Promise = asgardeoMiddleware( + async (asgardeo: AsgardeoMiddlewareContext): Promise => + asgardeo.protectRoute(options.redirect ? {redirect: options.redirect} : undefined), + options.signInUrl ? {signInUrl: options.signInUrl} : {}, + ); + + return middleware(new NextRequest(url, {headers: options.headers})); + }; + + beforeEach(() => { + // Unauthenticated requests (no session cookie); no sign-in URL unless a test sets one. + delete process.env['NEXT_PUBLIC_ASGARDEO_SIGN_IN_URL']; + }); + + afterEach(() => { + if (originalSignInUrl === undefined) { + delete process.env['NEXT_PUBLIC_ASGARDEO_SIGN_IN_URL']; + } else { + process.env['NEXT_PUBLIC_ASGARDEO_SIGN_IN_URL'] = originalSignInUrl; + } + }); + + it('redirects an unauthenticated request to the configured sign-in URL', async () => { + const response: NextResponse = await protect('http://localhost:3000/dashboard', {signInUrl: '/signin'}); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('http://localhost:3000/signin'); + }); + + it('prefers the redirect given to protectRoute', async () => { + const response: NextResponse = await protect('http://localhost:3000/dashboard', { + redirect: '/login', + signInUrl: '/signin', + }); + + expect(response.headers.get('location')).toBe('http://localhost:3000/login'); + }); + + it('falls back to a same-origin referer that is a different page', async () => { + const response: NextResponse = await protect('http://localhost:3000/dashboard', { + headers: {referer: 'http://localhost:3000/pricing?plan=team'}, + }); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('http://localhost:3000/pricing?plan=team'); + }); + + it('ignores a referer from another origin', async () => { + const response: NextResponse = await protect('http://localhost:3000/dashboard', { + headers: {referer: 'https://evil.example.com/phish'}, + }); + + expect(response.headers.get('location')).toBe('http://localhost:3000/'); + }); + + it('does not redirect to a referer that is the protected page itself', async () => { + // The browser keeps the referer of the page that started the navigation across the redirect chain, + // so this used to bounce between /dashboard/a and itself until ERR_TOO_MANY_REDIRECTS. + const response: NextResponse = await protect('http://localhost:3000/dashboard/a?tab=1', { + headers: {referer: 'http://localhost:3000/dashboard/a'}, + }); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('http://localhost:3000/'); + }); + + it('answers 401 instead of redirecting when the sign-in target is the protected route itself', async () => { + const response: NextResponse = await protect('http://localhost:3000/signin', {signInUrl: '/signin'}); + + expect(response.status).toBe(401); + expect(response.headers.get('location')).toBeNull(); + expect(await response.text()).toMatch(/signInUrl/); + }); + + it('answers 401 when the root is protected and nothing else can be redirected to', async () => { + const response: NextResponse = await protect('http://localhost:3000/'); + + expect(response.status).toBe(401); + }); +}); diff --git a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts index 0abda1db2..7e26b781a 100644 --- a/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts +++ b/packages/nextjs/src/server/middleware/asgardeoMiddleware.ts @@ -36,11 +36,14 @@ export type AsgardeoMiddlewareContext = { /** * Protect a route by redirecting unauthenticated users. * Redirect URL fallback order: - * 1. options.redirect - * 2. resolvedOptions.signInUrl - * 3. resolvedOptions.defaultRedirect - * 4. referer (if from same origin) + * 1. routeOptions.redirect + * 2. the configured `signInUrl` + * 3. the referer, when it is a same-origin page other than the requested one * If none are available, falls back to '/'. + * + * When the resolved target is the protected route itself (for example the sign-in page is covered by the + * protected matcher, or `/` is protected without a `signInUrl`), a `401` response is returned instead of a + * redirect, since redirecting would loop until the browser gives up. */ protectRoute: (routeOptions?: {redirect?: string}) => Promise; }; @@ -265,14 +268,18 @@ const asgardeoMiddleware = } if (!isAuthenticated) { + const requestUrl: URL = new URL(request.url); const referer: string | null = request.headers.get('referer'); let fallbackRedirect: string = '/'; if (referer) { try { const refererUrl: URL = new URL(referer); - const requestUrl: URL = new URL(request.url); - if (refererUrl.origin === requestUrl.origin) { + + // Only go "back" to a same-origin page other than the one being protected. Browsers keep the + // referer of the page that started the navigation across a redirect chain, so redirecting to a + // referer equal to the request would bounce between the two until the browser gives up. + if (refererUrl.origin === requestUrl.origin && refererUrl.pathname !== requestUrl.pathname) { fallbackRedirect = refererUrl.pathname + refererUrl.search; } } catch { @@ -282,8 +289,19 @@ const asgardeoMiddleware = const redirectUrl: string = routeOptions?.redirect ?? (resolvedConfig.signInUrl as string) ?? fallbackRedirect; + const redirectTarget: URL = new URL(redirectUrl, request.url); + + if (redirectTarget.origin === requestUrl.origin && redirectTarget.pathname === requestUrl.pathname) { + // Redirecting to the protected route itself would loop (ERR_TOO_MANY_REDIRECTS). This happens when + // the sign-in page is covered by the protected matcher, or `/` is protected without a `signInUrl`. + return new NextResponse( + `Unauthorized. The sign-in redirect (${redirectTarget.pathname}) points at the protected route itself. ` + + 'Configure `signInUrl` (NEXT_PUBLIC_ASGARDEO_SIGN_IN_URL) or exclude the sign-in page from the protected routes.', + {headers: {'Content-Type': 'text/plain'}, status: 401}, + ); + } - return NextResponse.redirect(new URL(redirectUrl, request.url)); + return NextResponse.redirect(redirectTarget); } return undefined;