diff --git a/.changeset/rsc-navigation-401-for-recoverable-sessions.md b/.changeset/rsc-navigation-401-for-recoverable-sessions.md new file mode 100644 index 00000000000..f0ecd7b9038 --- /dev/null +++ b/.changeset/rsc-navigation-401-for-recoverable-sessions.md @@ -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. diff --git a/packages/backend/src/__tests__/exports.test.ts b/packages/backend/src/__tests__/exports.test.ts index 7892bf9f554..189ea4d3e8d 100644 --- a/packages/backend/src/__tests__/exports.test.ts +++ b/packages/backend/src/__tests__/exports.test.ts @@ -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", diff --git a/packages/backend/src/internal.ts b/packages/backend/src/internal.ts index 27b44f31f98..f12edc70e51 100644 --- a/packages/backend/src/internal.ts +++ b/packages/backend/src/internal.ts @@ -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, diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 31757419d0f..b86b4ffb573 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -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'; @@ -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 = {}) => + 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).mockClear(); diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 9e6699de60d..b6750ea3abf 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -10,6 +10,7 @@ import type { UnauthenticatedState, } from '@clerk/backend/internal'; import { + AuthErrorReason, AuthStatus, constants, createBootstrapSignedOutState, @@ -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'; @@ -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, @@ -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-` 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 @@ -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, diff --git a/packages/nextjs/src/server/nextErrors.ts b/packages/nextjs/src/server/nextErrors.ts index fc205f7ac89..9aa7b905f95 100644 --- a/packages/nextjs/src/server/nextErrors.ts +++ b/packages/nextjs/src/server/nextErrors.ts @@ -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; @@ -178,6 +186,7 @@ export { isNextjsRedirectError, isRedirectToSignInError, isRedirectToSignUpError, + isRedirectToUrlError, isNextjsUnauthorizedError, unauthorized, }; diff --git a/packages/nextjs/src/server/protect.ts b/packages/nextjs/src/server/protect.ts index 1e72f7128fd..1f16c3a5cfd 100644 --- a/packages/nextjs/src/server/protect.ts +++ b/packages/nextjs/src/server/protect.ts @@ -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') ||