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
6 changes: 6 additions & 0 deletions .changeset/rsc-navigation-401-for-recoverable-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/nextjs': patch
'@clerk/backend': patch
---

App Router client-side navigations that arrive in a recoverable signed-out session state (for example an expired but refreshable session token) are now answered with a `401` instead of a Clerk redirect. The Next.js router falls back to a full document navigation, which can recover the session through a handshake, rather than soft-navigating the user to the sign-in page or leaving the navigation hanging. Users who are genuinely signed out still get the usual redirect, so signing out and navigating to a sign-in page stays instant.
1 change: 1 addition & 0 deletions packages/backend/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe('subpath /internal exports', () => {
it('should not include a breaking change', () => {
expect(Object.keys(internalExports).sort()).toMatchInlineSnapshot(`
[
"AuthErrorReason",
"AuthStatus",
"TokenType",
"authenticatedMachineObject",
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export {
getAuthObjectForAcceptedToken,
} from './tokens/authObjects';

export { AuthStatus, createBootstrapSignedOutState } from './tokens/authStatus';
export { AuthErrorReason, AuthStatus, createBootstrapSignedOutState } from './tokens/authStatus';
export type {
RequestState,
SignedInState,
Expand Down
155 changes: 154 additions & 1 deletion packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// There is no need to execute the complete authenticateRequest to test clerkMiddleware
// This mock SHOULD exist before the import of authenticateRequest
import { AuthStatus, constants, TokenType } from '@clerk/backend/internal';
import { AuthErrorReason, AuthStatus, constants, TokenType } from '@clerk/backend/internal';
import { MalformedURLError } from '@clerk/shared/pathMatcher';
// used to assert the mock
import assert from 'assert';
Expand Down Expand Up @@ -993,6 +993,159 @@ describe('clerkMiddleware(params)', () => {
});
});

describe('App Router RSC navigations with a recoverable signed-out session', () => {
const expiredReason = `${AuthErrorReason.SessionTokenExpired}-refresh-refresh-token-missing`;

const mockSignedOut = (reason: string) => {
authenticateRequestMock.mockResolvedValueOnce({
publishableKey,
status: AuthStatus.SignedOut,
reason,
headers: new Headers(),
toAuth: () => ({ tokenType: TokenType.SessionToken, userId: null }),
});
};

const rscRequest = (extraHeaders: Record<string, string> = {}) =>
mockRequest({
url: '/protected',
headers: new Headers({
'next-url': '/protected',
[constants.Headers.Accept]: '*/*',
[constants.Headers.SecFetchDest]: 'empty',
...extraHeaders,
}),
appendDevBrowserCookie: true,
});

it('returns 401 instead of redirecting to sign-in when protect is called', async () => {
mockSignedOut(expiredReason);

const resp = await clerkMiddleware(async auth => {
await auth.protect();
})(rscRequest(), {} as NextFetchEvent);

expect(resp?.status).toEqual(401);
expect(resp?.headers.get(constants.Headers.CacheControl)).toEqual('no-store');
expect(resp?.headers.get('location')).toBeFalsy();
expect((await clerkClient()).authenticateRequest).toBeCalled();
});

it('returns 401 instead of redirecting when redirectToSignIn is called directly', async () => {
mockSignedOut(expiredReason);

const resp = await clerkMiddleware(async auth => {
(await auth()).redirectToSignIn();
})(rscRequest(), {} as NextFetchEvent);

expect(resp?.status).toEqual(401);
expect(resp?.headers.get(constants.Headers.CacheControl)).toEqual('no-store');
expect(resp?.headers.get('location')).toBeFalsy();
});

it('returns 401 instead of redirecting to the unauthenticatedUrl passed to protect', async () => {
mockSignedOut(expiredReason);

const resp = await clerkMiddleware(async auth => {
await auth.protect({ unauthenticatedUrl: 'https://www.clerk.com/unauthenticatedUrl' });
})(rscRequest(), {} as NextFetchEvent);

expect(resp?.status).toEqual(401);
expect(resp?.headers.get(constants.Headers.CacheControl)).toEqual('no-store');
expect(resp?.headers.get('location')).toBeFalsy();
});

it.each([
AuthErrorReason.ClientUATWithoutSessionToken,
AuthErrorReason.SessionTokenIATBeforeClientUAT,
AuthErrorReason.SessionTokenWithoutClientUAT,
AuthErrorReason.DevBrowserMissing,
AuthErrorReason.DevBrowserSync,
AuthErrorReason.SessionTokenNBF,
AuthErrorReason.SessionTokenIatInTheFuture,
])('returns 401 for the recoverable reason %s', async reason => {
mockSignedOut(reason);

const resp = await clerkMiddleware(async auth => {
await auth.protect();
})(rscRequest(), {} as NextFetchEvent);

expect(resp?.status).toEqual(401);
});

it('still redirects when the request is a document request', async () => {
mockSignedOut(expiredReason);

const req = mockRequest({
url: '/protected',
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
appendDevBrowserCookie: true,
});

const resp = await clerkMiddleware(async auth => {
await auth.protect();
})(req, {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
});

it('still redirects when the signed-out reason is not recoverable', async () => {
mockSignedOut(AuthErrorReason.SessionTokenAndUATMissing);

const resp = await clerkMiddleware(async auth => {
await auth.protect();
})(rscRequest(), {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
});

it('still redirects when the Accept header is not the RSC fetch signature', async () => {
mockSignedOut(expiredReason);

const resp = await clerkMiddleware(async auth => {
await auth.protect();
})(rscRequest({ [constants.Headers.Accept]: 'application/json' }), {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
});

it('still redirects for Pages Router internal navigations', async () => {
mockSignedOut(expiredReason);

const resp = await clerkMiddleware(async auth => {
await auth.protect();
})(rscRequest({ 'x-nextjs-data': '1' }), {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
});

it('still redirects for non-GET requests', async () => {
mockSignedOut(expiredReason);

const req = mockRequest({
url: '/protected',
method: 'POST',
headers: new Headers({
'next-url': '/protected',
[constants.Headers.Accept]: '*/*',
[constants.Headers.SecFetchDest]: 'empty',
}),
appendDevBrowserCookie: true,
});

const resp = await clerkMiddleware(async auth => {
(await auth()).redirectToSignIn();
})(req, {} as NextFetchEvent);

expect(resp?.status).toEqual(307);
expect(resp?.headers.get('location')).toContain('sign-in');
});
});

describe('debug', () => {
beforeEach(() => {
(global.console.log as ReturnType<typeof vi.fn>).mockClear();
Expand Down
63 changes: 62 additions & 1 deletion packages/nextjs/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
UnauthenticatedState,
} from '@clerk/backend/internal';
import {
AuthErrorReason,
AuthStatus,
constants,
createBootstrapSignedOutState,
Expand All @@ -31,6 +32,7 @@ import type { NextMiddleware, NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

import type { AuthFn } from '../app-router/server/auth';
import { constants as nextConstants } from '../constants';
import type { GetAuthOptions } from '../server/createGetAuth';
import { isRedirect, serverRedirectWithAuth, setHeader } from '../utils';
import type { Logger, LoggerNoCommit } from '../utils/debugLogger';
Expand All @@ -49,13 +51,14 @@ import {
isNextjsUnauthorizedError,
isRedirectToSignInError,
isRedirectToSignUpError,
isRedirectToUrlError,
nextjsRedirectError,
redirectToSignInError,
redirectToSignUpError,
unauthorized,
} from './nextErrors';
import type { AuthProtect } from './protect';
import { createProtect } from './protect';
import { createProtect, isServerActionRequest } from './protect';
import type {
FrontendApiProxyOptions,
NextMiddlewareEvtParam,
Expand Down Expand Up @@ -608,6 +611,55 @@ const createMiddlewareAuthHandler = (
return authHandler as ClerkMiddlewareAuth;
};

const RECOVERABLE_SIGNED_OUT_REASONS: string[] = [
AuthErrorReason.ClientUATWithoutSessionToken,
AuthErrorReason.SessionTokenIATBeforeClientUAT,
AuthErrorReason.SessionTokenWithoutClientUAT,
AuthErrorReason.DevBrowserMissing,
AuthErrorReason.DevBrowserSync,
AuthErrorReason.SessionTokenNBF,
AuthErrorReason.SessionTokenIatInTheFuture,
];

const isRecoverableSignedOutReason = (reason: string | null | undefined) => {
if (!reason) {
return false;
}

// Expired-token reasons carry a `-refresh-<error>` suffix, see convertTokenVerificationErrorReasonToAuthErrorReason
if (reason.startsWith(AuthErrorReason.SessionTokenExpired)) {
return true;
}

return RECOVERABLE_SIGNED_OUT_REASONS.includes(reason);
};

const isAppRouterNavigationFetch = (req: NextRequest) => {
if (req.method !== 'GET') {
return false;
}

if (!req.headers.get(nextConstants.Headers.NextUrl)) {
return false;
}

if (isServerActionRequest(req) || req.headers.get(nextConstants.Headers.NextjsData)) {
return false;
}

const secFetchDest = req.headers.get(constants.Headers.SecFetchDest);
if (secFetchDest === 'document' || secFetchDest === 'iframe') {
return false;
}

// RSC navigation fetches set no Accept header, so the browser default `*/*` applies
return req.headers.get(constants.Headers.Accept)?.trim() === '*/*';
};

// An RSC navigation cannot run a handshake, so a 401 is answered with a document navigation that can
const isRecoverableRscNavigation = (req: NextRequest, requestState: RequestState) =>
isRecoverableSignedOutReason(requestState.reason) && isAppRouterNavigationFetch(req);

// Handle errors thrown by protect() and redirectToSignIn() calls,
// as we want to align the APIs between middleware, pages and route handlers
// Normally, middleware requires to explicitly return a response, but we want to
Expand Down Expand Up @@ -661,6 +713,15 @@ const handleControlFlowErrors = (
const isRedirectToSignIn = isRedirectToSignInError(e);
const isRedirectToSignUp = isRedirectToSignUpError(e);

if (
(isRedirectToSignIn || isRedirectToSignUp || isRedirectToUrlError(e)) &&
isRecoverableRscNavigation(nextRequest, requestState)
) {
const response = new NextResponse(null, { status: 401 });
response.headers.set(constants.Headers.CacheControl, 'no-store');
return response;
}

if (isRedirectToSignIn || isRedirectToSignUp) {
const redirect = createRedirect({
redirectAdapter,
Expand Down
9 changes: 9 additions & 0 deletions packages/nextjs/src/server/nextErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ function isRedirectToSignInError(error: unknown): error is RedirectError<{ retur
return false;
}

function isRedirectToUrlError(error: unknown): error is RedirectError<{ redirectUrl: string | URL }> {
if (isNextjsRedirectError(error) && 'clerk_digest' in error) {
return error.clerk_digest === CONTROL_FLOW_ERROR.REDIRECT_TO_URL;
}

return false;
}

function isRedirectToSignUpError(error: unknown): error is RedirectError<{ returnBackUrl: string | URL }> {
if (isNextjsRedirectError(error) && 'clerk_digest' in error) {
return error.clerk_digest === CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_UP;
Expand Down Expand Up @@ -178,6 +186,7 @@ export {
isNextjsRedirectError,
isRedirectToSignInError,
isRedirectToSignUpError,
isRedirectToUrlError,
isNextjsUnauthorizedError,
unauthorized,
};
2 changes: 1 addition & 1 deletion packages/nextjs/src/server/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ const getAuthorizationParams = (arg: any) => {
return authParams as CheckAuthorizationParamsWithCustomPermissions;
};

const isServerActionRequest = (req: Request) => {
export const isServerActionRequest = (req: Request) => {
return (
!!req.headers.get(nextConstants.Headers.NextUrl) &&
(req.headers.get(constants.Headers.Accept)?.includes('text/x-component') ||
Expand Down
Loading