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..56c774dcd --- /dev/null +++ b/src/app/api/auth/sign-out/route.ts @@ -0,0 +1,17 @@ +import 'server-only' + +import { type NextRequest, NextResponse } from 'next/server' +import { auth } from '@/core/server/auth' + +// 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/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts index e182391c8..c4e42589a 100644 --- a/src/core/server/actions/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -384,13 +384,6 @@ export const forgotPasswordAction = actionClient } }) -export async function signOutAction(returnTo?: string) { - const origin = (await headers()).get('origin') ?? undefined - const { redirectTo } = await auth.signOut({ origin, returnTo }) - - throw redirect(redirectTo) -} - // Drives the account-settings re-authentication step and returns the URL the // client should HARD-navigate to. Supabase signs the user out and bounces // through /sign-in (which lands back on the account page with ?reauth=1); Ory diff --git a/src/core/server/auth/ory/signout-flow.ts b/src/core/server/auth/ory/signout-flow.ts index cf08004f3..75b5a0090 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,5 @@ export async function completeOrySignOut(origin = BASE_URL): Promise { identityId ? revokeKratosSessionsForIdentity(identityId) : null, ]) - const logoutUrl = idToken ? buildOryLogoutUrl({ idToken, origin }) : null - return (logoutUrl ?? postLogoutUrl).toString() + 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 1e05b89fa..5e97d2e44 100644 --- a/src/features/dashboard/sidebar/menu.tsx +++ b/src/features/dashboard/sidebar/menu.tsx @@ -6,7 +6,6 @@ import { usePostHog } from 'posthog-js/react' import { useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { getTeamDisplayName } from '@/core/modules/teams/utils' -import { signOutAction } from '@/core/server/actions/auth-actions' import { resetOryPostHogIdentity } from '@/features/ory-posthog-identity-bridge' import { useAppPostHogProvider } from '@/features/posthog-provider' import { cn } from '@/lib/utils' @@ -36,18 +35,18 @@ export default function DashboardSidebarMenu() { const { enabled: postHogEnabled } = useAppPostHogProvider() const posthog = usePostHog() const [createTeamOpen, setCreateTeamOpen] = useState(false) - // explicit state instead of useTransition: a sync transition callback - // settles immediately, so isPending would flip back to false while the - // sign-out action is still in flight; this stays true until the redirect - // navigates away, and only resets if the action 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 handleLogout = () => { setIsLoggingOut(true) if (postHogEnabled) resetOryPostHogIdentity(posthog) - signOutAction().catch(() => { - setIsLoggingOut(false) - }) + // 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 c32e32a62..896517cb2 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -1,11 +1,11 @@ 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 { forgotPasswordAction, signInAction, signInWithOAuthAction, - signOutAction, signUpAction, } from '@/core/server/actions/auth-actions' import { encodedRedirect } from '@/lib/utils/auth' @@ -506,38 +506,26 @@ describe('Auth Actions - Integration Tests', () => { }) }) - describe('Sign Out Flow', () => { - /** - * AUTHENTICATION TEST: Verifies that sign-out redirects to sign-in page - */ - it('should redirect to sign-in page on sign-out', async () => { - // Setup: Mock Supabase client to return successful sign-out - mockSupabaseClient.auth.signOut.mockResolvedValue({ - error: null, - }) - - // Execute and Verify: Call the sign-out action and expect it to throw redirect - await expect(signOutAction()).rejects.toEqual( - expect.objectContaining({ - destination: AUTH_URLS.SIGN_IN, - }) - ) - }) + 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 redirects to sign-in page with returnTo + * 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 an auth()-wrapped request — is + * what keeps the deletion from being clobbered by a re-issued JWT cookie. */ - it('should redirect to sign-in page with returnTo parameter', async () => { - // Setup: Mock Supabase client to return successful sign-out - mockSupabaseClient.auth.signOut.mockResolvedValue({ - error: null, - }) + it('clears the session and redirects to the sign-in page', async () => { + mockSupabaseClient.auth.signOut.mockResolvedValue({ error: null }) + + const response = await callSignOutRoute() - // Execute and Verify: Call the sign-out action and expect it to throw redirect - await expect(signOutAction('/dashboard')).rejects.toEqual( - expect.objectContaining({ - destination: `${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}` ) }) })