Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nextjs-protect-route-redirect-loop.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, string>; redirect?: string; signInUrl?: string} = {},
): Promise<NextResponse> => {
const middleware: (request: NextRequest) => Promise<NextResponse> = asgardeoMiddleware(
async (asgardeo: AsgardeoMiddlewareContext): Promise<NextResponse | void> =>
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);
});
});
32 changes: 25 additions & 7 deletions packages/nextjs/src/server/middleware/asgardeoMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextResponse | void>;
};
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
Loading