From 602b595e8285e5380ee80146c6089572de05550f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Thu, 11 Jun 2026 17:39:12 +0200 Subject: [PATCH 1/3] fix(auth): clear session in a plain route handler so logout sticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign-out ran inside the auth.signOut tRPC mutation, whose route is wrapped by the Auth.js auth() helper. With the JWT session strategy that wrapper re-issues a refreshed session cookie at the end of the request, clobbering the Set-Cookie deletion that signOut() emits — so the authjs.session-token survived and /dashboard stayed authenticated after logout. Move sign-out to a dedicated GET /api/auth/sign-out route (not auth()-wrapped), hard-navigated to by the logout button, so the cookie deletion is the final word and the overlay stays up until unload. Skip the Ory RP-initiated logout round-trip and redirect straight to the post-logout page, relying on the existing Kratos + Hydra admin revocations. Remove the broken tRPC signOut procedure. --- src/app/api/auth/sign-out/route.ts | 16 +++++++ src/core/server/api/routers/auth.ts | 25 ----------- src/core/server/auth/ory/signout-flow.ts | 12 +++--- src/features/dashboard/sidebar/menu.tsx | 29 +++---------- tests/integration/auth.test.ts | 55 +++++++----------------- 5 files changed, 44 insertions(+), 93 deletions(-) create mode 100644 src/app/api/auth/sign-out/route.ts diff --git a/src/app/api/auth/sign-out/route.ts b/src/app/api/auth/sign-out/route.ts new file mode 100644 index 000000000..0415dafcc --- /dev/null +++ b/src/app/api/auth/sign-out/route.ts @@ -0,0 +1,16 @@ +import 'server-only' + +import { type NextRequest, NextResponse } from 'next/server' +import { auth } from '@/core/server/auth' + +// Sign-out lives in a plain route handler — NOT the auth()-wrapped tRPC route. +// The Auth.js route wrapper re-issues a refreshed JWT session cookie at the end +// of every request, which would clobber the session-cookie deletion that +// signOut() emits, leaving the user logged in. Here nothing re-wraps the +// request, so the deletion sticks. The client hard-navigates to this route, so +// the logout overlay stays up until the document unloads (no soft RSC redirect +// re-rendering the signed-out dashboard underneath it). +export async function GET(request: NextRequest) { + const { redirectTo } = await auth.signOut({ origin: request.nextUrl.origin }) + return NextResponse.redirect(new URL(redirectTo, request.nextUrl.origin)) +} diff --git a/src/core/server/api/routers/auth.ts b/src/core/server/api/routers/auth.ts index aa7e731e2..7731b835f 100644 --- a/src/core/server/api/routers/auth.ts +++ b/src/core/server/api/routers/auth.ts @@ -1,10 +1,7 @@ -import { z } from 'zod' import { ConfirmEmailInputSchema } from '@/core/modules/auth/models' -import { auth } from '@/core/server/auth' import { verifyOtpAndBuildRedirect } from '@/core/server/auth/verify-otp' import { createTRPCRouter } from '@/core/server/trpc/init' import { publicProcedure } from '@/core/server/trpc/procedures' -import { relativeUrlSchema } from '@/core/shared/schemas/url' export const authRouter = createTRPCRouter({ verifyOtp: publicProcedure @@ -12,26 +9,4 @@ export const authRouter = createTRPCRouter({ .mutation(({ ctx, input }) => verifyOtpAndBuildRedirect(input, ctx.requestOrigin) ), - - // Returns the URL the client should HARD-navigate to (via window.location) - // rather than redirect()-ing server-side: a soft RSC navigation re-renders the - // signed-out dashboard and tears down the "Logging out..." overlay before the - // browser leaves the page. publicProcedure (no auth guard) keeps sign-out - // resilient even if the session is already gone. - signOut: publicProcedure - .input( - z - .object({ - returnTo: relativeUrlSchema.optional(), - }) - .optional() - ) - .mutation(async ({ ctx, input }) => { - const { redirectTo } = await auth.signOut({ - origin: ctx.requestOrigin, - returnTo: input?.returnTo, - }) - - return { url: redirectTo } - }), }) diff --git a/src/core/server/auth/ory/signout-flow.ts b/src/core/server/auth/ory/signout-flow.ts index cf08004f3..998ff7ec3 100644 --- a/src/core/server/auth/ory/signout-flow.ts +++ b/src/core/server/auth/ory/signout-flow.ts @@ -6,19 +6,15 @@ import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { readOrySessionFields } from './authjs-session-boundary' import { revokeKratosSessionsForIdentity } from './kratos-session' import { revokeOryOAuthSessionsForSubject } from './oauth-session' -import { buildOryLogoutUrl, ORY_POST_LOGOUT_PATH } from './signout' +import { ORY_POST_LOGOUT_PATH } from './signout' export async function completeOrySignOut(origin = BASE_URL): Promise { - const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin) - - let idToken: string | undefined let identityId: string | undefined let userId: string | undefined try { const session = await auth() const serverFields = readOrySessionFields(session) userId = session?.user?.id - idToken = serverFields?.idToken // The Kratos identity id resolved at sign-in — NOT the OIDC subject (which // is the E2B user id) — so we revoke the right identity's Kratos sessions. identityId = serverFields?.identityId @@ -53,6 +49,8 @@ export async function completeOrySignOut(origin = BASE_URL): Promise { identityId ? revokeKratosSessionsForIdentity(identityId) : null, ]) - const logoutUrl = idToken ? buildOryLogoutUrl({ idToken, origin }) : null - return (logoutUrl ?? postLogoutUrl).toString() + // The admin revocations above already terminate the Kratos + Hydra sessions + // server-side, so we skip the Ory RP-initiated logout round-trip and redirect + // straight to the post-logout page — one redirect, no cross-origin hop. + return new URL(ORY_POST_LOGOUT_PATH, origin).toString() } diff --git a/src/features/dashboard/sidebar/menu.tsx b/src/features/dashboard/sidebar/menu.tsx index f5d32b222..010715815 100644 --- a/src/features/dashboard/sidebar/menu.tsx +++ b/src/features/dashboard/sidebar/menu.tsx @@ -1,13 +1,11 @@ 'use client' import { Portal } from '@radix-ui/react-portal' -import { useMutation } from '@tanstack/react-query' import Link from 'next/link' import { useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { getTeamDisplayName } from '@/core/modules/teams/utils' import { cn } from '@/lib/utils' -import { useTRPC } from '@/trpc/client' import { DropdownMenu, DropdownMenuContent, @@ -31,31 +29,18 @@ import { TeamAvatar } from './team-avatar' export default function DashboardSidebarMenu() { const { team } = useDashboard() - const trpc = useTRPC() const [createTeamOpen, setCreateTeamOpen] = useState(false) - // explicit state instead of the mutation's isPending: isPending flips back to - // false the moment the mutation resolves, which would tear down the overlay a - // beat before the hard navigation unloads the page; this stays true until we - // navigate away, and only resets if the sign-out fails before that + // Stays true until the hard navigation unloads the page; the overlay should + // never tear down before then. const [isLoggingOut, setIsLoggingOut] = useState(false) - const signOutMutation = useMutation( - trpc.auth.signOut.mutationOptions({ - onSuccess: ({ url }) => { - // Hard navigation (not the Next router): a soft RSC redirect re-renders - // the dashboard and tears down this overlay before the browser leaves - // the page. window.location keeps the overlay up until unload. - window.location.href = url - }, - onError: () => { - setIsLoggingOut(false) - }, - }) - ) - const handleLogout = () => { setIsLoggingOut(true) - signOutMutation.mutate({}) + // Hard navigation (not the Next router) to a plain route handler that clears + // the session cookie server-side: a soft RSC redirect would re-render the + // signed-out dashboard and tear down this overlay before the browser leaves + // the page. window.location keeps the overlay up until unload. + window.location.href = '/api/auth/sign-out' } return ( diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts index 764300d58..abd3bb835 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -1,4 +1,5 @@ import { redirect } from 'next/navigation' +import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { @@ -7,8 +8,6 @@ import { signInWithOAuthAction, signUpAction, } from '@/core/server/actions/auth-actions' -import { authRouter } from '@/core/server/api/routers/auth' -import { createCallerFactory, createTRPCContext } from '@/core/server/trpc/init' import { encodedRedirect } from '@/lib/utils/auth' // Create hoisted mock functions that can be used throughout the file @@ -513,48 +512,26 @@ describe('Auth Actions - Integration Tests', () => { }) }) - describe('Sign Out Flow (trpc auth.signOut)', () => { - const createCaller = createCallerFactory(authRouter) - - const getCaller = async () => - createCaller( - await createTRPCContext({ - headers: new Headers(), - requestUrl: 'http://localhost:3000/api/trpc', - }) - ) + describe('Sign Out Flow (GET /api/auth/sign-out)', () => { + const callSignOutRoute = async () => { + const { GET } = await import('@/app/api/auth/sign-out/route') + return GET(new NextRequest('https://app.e2b.dev/api/auth/sign-out')) + } /** - * AUTHENTICATION TEST: Verifies that sign-out returns the sign-in page url + * AUTHENTICATION TEST: the plain (non-auth()-wrapped) route handler clears + * the session via the provider and redirects to the sign-in page. Running + * the cookie clear here — rather than inside the auth()-wrapped tRPC route — + * is what keeps the deletion from being clobbered by a re-issued JWT cookie. */ - it('should return sign-in page url on sign-out', async () => { - // Setup: Mock Supabase client to return successful sign-out - mockSupabaseClient.auth.signOut.mockResolvedValue({ - error: null, - }) - - // Execute and Verify: the mutation returns the URL for the client to hard-navigate to - const caller = await getCaller() - await expect(caller.signOut()).resolves.toEqual({ - url: AUTH_URLS.SIGN_IN, - }) - }) + it('clears the session and redirects to the sign-in page', async () => { + mockSupabaseClient.auth.signOut.mockResolvedValue({ error: null }) - /** - * AUTHENTICATION TEST: Verifies that sign-out returns the sign-in url with returnTo - */ - it('should return sign-in page url with returnTo parameter', async () => { - // Setup: Mock Supabase client to return successful sign-out - mockSupabaseClient.auth.signOut.mockResolvedValue({ - error: null, - }) + const response = await callSignOutRoute() - // Execute and Verify: the mutation returns the URL for the client to hard-navigate to - const caller = await getCaller() - await expect(caller.signOut({ returnTo: '/dashboard' })).resolves.toEqual( - { - url: `${AUTH_URLS.SIGN_IN}?returnTo=${encodeURIComponent('/dashboard')}`, - } + expect(mockSupabaseClient.auth.signOut).toHaveBeenCalled() + expect(response.headers.get('location')).toBe( + `https://app.e2b.dev${AUTH_URLS.SIGN_IN}` ) }) }) From 1fe89396b236c135dac97b3dbf4bb08b47f970db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Fri, 12 Jun 2026 13:36:42 +0200 Subject: [PATCH 2/3] docs(auth): drop stale tRPC references in sign-out comments --- src/app/api/auth/sign-out/route.ts | 15 ++++++++------- tests/integration/auth.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/app/api/auth/sign-out/route.ts b/src/app/api/auth/sign-out/route.ts index 0415dafcc..56c774dcd 100644 --- a/src/app/api/auth/sign-out/route.ts +++ b/src/app/api/auth/sign-out/route.ts @@ -3,13 +3,14 @@ import 'server-only' import { type NextRequest, NextResponse } from 'next/server' import { auth } from '@/core/server/auth' -// Sign-out lives in a plain route handler — NOT the auth()-wrapped tRPC route. -// The Auth.js route wrapper re-issues a refreshed JWT session cookie at the end -// of every request, which would clobber the session-cookie deletion that -// signOut() emits, leaving the user logged in. Here nothing re-wraps the -// request, so the deletion sticks. The client hard-navigates to this route, so -// the logout overlay stays up until the document unloads (no soft RSC redirect -// re-rendering the signed-out dashboard underneath it). +// Sign-out lives in a plain route handler, deliberately NOT wrapped by the +// Auth.js `auth()` helper. When sign-out runs inside an auth()-wrapped request, +// the wrapper re-issues a refreshed JWT session cookie at the end of the +// request, which clobbers the session-cookie deletion that signOut() emits and +// leaves the user logged in. Here nothing re-wraps the request, so the deletion +// sticks. The client hard-navigates to this route, so the logout overlay stays +// up until the document unloads (no soft RSC redirect re-rendering the +// signed-out dashboard underneath it). export async function GET(request: NextRequest) { const { redirectTo } = await auth.signOut({ origin: request.nextUrl.origin }) return NextResponse.redirect(new URL(redirectTo, request.nextUrl.origin)) diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts index 99c6a671f..896517cb2 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -515,8 +515,8 @@ describe('Auth Actions - Integration Tests', () => { /** * AUTHENTICATION TEST: the plain (non-auth()-wrapped) route handler clears * the session via the provider and redirects to the sign-in page. Running - * the cookie clear here — rather than inside the auth()-wrapped tRPC route — - * is what keeps the deletion from being clobbered by a re-issued JWT cookie. + * the cookie clear here — rather than inside an auth()-wrapped request — is + * what keeps the deletion from being clobbered by a re-issued JWT cookie. */ it('clears the session and redirects to the sign-in page', async () => { mockSupabaseClient.auth.signOut.mockResolvedValue({ error: null }) From c41b1b78ba5680cfa59d685acbc16d712bde730b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Fri, 12 Jun 2026 20:19:12 +0200 Subject: [PATCH 3/3] Update src/core/server/auth/ory/signout-flow.ts Co-authored-by: Ben Fornefeld <50748440+ben-fornefeld@users.noreply.github.com> --- src/core/server/auth/ory/signout-flow.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/core/server/auth/ory/signout-flow.ts b/src/core/server/auth/ory/signout-flow.ts index 998ff7ec3..75b5a0090 100644 --- a/src/core/server/auth/ory/signout-flow.ts +++ b/src/core/server/auth/ory/signout-flow.ts @@ -49,8 +49,5 @@ export async function completeOrySignOut(origin = BASE_URL): Promise { identityId ? revokeKratosSessionsForIdentity(identityId) : null, ]) - // The admin revocations above already terminate the Kratos + Hydra sessions - // server-side, so we skip the Ory RP-initiated logout round-trip and redirect - // straight to the post-logout page — one redirect, no cross-origin hop. return new URL(ORY_POST_LOGOUT_PATH, origin).toString() }