Skip to content

Commit 194cafd

Browse files
huv1kben-fornefeld
andauthored
fix(auth): clear session in a plain route handler so logout sticks (#398)
Co-authored-by: Ben Fornefeld <50748440+ben-fornefeld@users.noreply.github.com>
1 parent bdb2a21 commit 194cafd

5 files changed

Lines changed: 43 additions & 51 deletions

File tree

src/app/api/auth/sign-out/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import 'server-only'
2+
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { auth } from '@/core/server/auth'
5+
6+
// Sign-out lives in a plain route handler, deliberately NOT wrapped by the
7+
// Auth.js `auth()` helper. When sign-out runs inside an auth()-wrapped request,
8+
// the wrapper re-issues a refreshed JWT session cookie at the end of the
9+
// request, which clobbers the session-cookie deletion that signOut() emits and
10+
// leaves the user logged in. Here nothing re-wraps the request, so the deletion
11+
// sticks. The client hard-navigates to this route, so the logout overlay stays
12+
// up until the document unloads (no soft RSC redirect re-rendering the
13+
// signed-out dashboard underneath it).
14+
export async function GET(request: NextRequest) {
15+
const { redirectTo } = await auth.signOut({ origin: request.nextUrl.origin })
16+
return NextResponse.redirect(new URL(redirectTo, request.nextUrl.origin))
17+
}

src/core/server/actions/auth-actions.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -384,13 +384,6 @@ export const forgotPasswordAction = actionClient
384384
}
385385
})
386386

387-
export async function signOutAction(returnTo?: string) {
388-
const origin = (await headers()).get('origin') ?? undefined
389-
const { redirectTo } = await auth.signOut({ origin, returnTo })
390-
391-
throw redirect(redirectTo)
392-
}
393-
394387
// Drives the account-settings re-authentication step and returns the URL the
395388
// client should HARD-navigate to. Supabase signs the user out and bounces
396389
// through /sign-in (which lands back on the account page with ?reauth=1); Ory

src/core/server/auth/ory/signout-flow.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,15 @@ import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
66
import { readOrySessionFields } from './authjs-session-boundary'
77
import { revokeKratosSessionsForIdentity } from './kratos-session'
88
import { revokeOryOAuthSessionsForSubject } from './oauth-session'
9-
import { buildOryLogoutUrl, ORY_POST_LOGOUT_PATH } from './signout'
9+
import { ORY_POST_LOGOUT_PATH } from './signout'
1010

1111
export async function completeOrySignOut(origin = BASE_URL): Promise<string> {
12-
const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin)
13-
14-
let idToken: string | undefined
1512
let identityId: string | undefined
1613
let userId: string | undefined
1714
try {
1815
const session = await auth()
1916
const serverFields = readOrySessionFields(session)
2017
userId = session?.user?.id
21-
idToken = serverFields?.idToken
2218
// The Kratos identity id resolved at sign-in — NOT the OIDC subject (which
2319
// is the E2B user id) — so we revoke the right identity's Kratos sessions.
2420
identityId = serverFields?.identityId
@@ -53,6 +49,5 @@ export async function completeOrySignOut(origin = BASE_URL): Promise<string> {
5349
identityId ? revokeKratosSessionsForIdentity(identityId) : null,
5450
])
5551

56-
const logoutUrl = idToken ? buildOryLogoutUrl({ idToken, origin }) : null
57-
return (logoutUrl ?? postLogoutUrl).toString()
52+
return new URL(ORY_POST_LOGOUT_PATH, origin).toString()
5853
}

src/features/dashboard/sidebar/menu.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { usePostHog } from 'posthog-js/react'
66
import { useState } from 'react'
77
import { PROTECTED_URLS } from '@/configs/urls'
88
import { getTeamDisplayName } from '@/core/modules/teams/utils'
9-
import { signOutAction } from '@/core/server/actions/auth-actions'
109
import { resetOryPostHogIdentity } from '@/features/ory-posthog-identity-bridge'
1110
import { useAppPostHogProvider } from '@/features/posthog-provider'
1211
import { cn } from '@/lib/utils'
@@ -36,18 +35,18 @@ export default function DashboardSidebarMenu() {
3635
const { enabled: postHogEnabled } = useAppPostHogProvider()
3736
const posthog = usePostHog()
3837
const [createTeamOpen, setCreateTeamOpen] = useState(false)
39-
// explicit state instead of useTransition: a sync transition callback
40-
// settles immediately, so isPending would flip back to false while the
41-
// sign-out action is still in flight; this stays true until the redirect
42-
// navigates away, and only resets if the action fails before that
38+
// Stays true until the hard navigation unloads the page; the overlay should
39+
// never tear down before then.
4340
const [isLoggingOut, setIsLoggingOut] = useState(false)
4441

4542
const handleLogout = () => {
4643
setIsLoggingOut(true)
4744
if (postHogEnabled) resetOryPostHogIdentity(posthog)
48-
signOutAction().catch(() => {
49-
setIsLoggingOut(false)
50-
})
45+
// Hard navigation (not the Next router) to a plain route handler that clears
46+
// the session cookie server-side: a soft RSC redirect would re-render the
47+
// signed-out dashboard and tear down this overlay before the browser leaves
48+
// the page. window.location keeps the overlay up until unload.
49+
window.location.href = '/api/auth/sign-out'
5150
}
5251

5352
return (

tests/integration/auth.test.ts

Lines changed: 17 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { redirect } from 'next/navigation'
2+
import { NextRequest } from 'next/server'
23
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
34
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
45
import {
56
forgotPasswordAction,
67
signInAction,
78
signInWithOAuthAction,
8-
signOutAction,
99
signUpAction,
1010
} from '@/core/server/actions/auth-actions'
1111
import { encodedRedirect } from '@/lib/utils/auth'
@@ -506,38 +506,26 @@ describe('Auth Actions - Integration Tests', () => {
506506
})
507507
})
508508

509-
describe('Sign Out Flow', () => {
510-
/**
511-
* AUTHENTICATION TEST: Verifies that sign-out redirects to sign-in page
512-
*/
513-
it('should redirect to sign-in page on sign-out', async () => {
514-
// Setup: Mock Supabase client to return successful sign-out
515-
mockSupabaseClient.auth.signOut.mockResolvedValue({
516-
error: null,
517-
})
518-
519-
// Execute and Verify: Call the sign-out action and expect it to throw redirect
520-
await expect(signOutAction()).rejects.toEqual(
521-
expect.objectContaining({
522-
destination: AUTH_URLS.SIGN_IN,
523-
})
524-
)
525-
})
509+
describe('Sign Out Flow (GET /api/auth/sign-out)', () => {
510+
const callSignOutRoute = async () => {
511+
const { GET } = await import('@/app/api/auth/sign-out/route')
512+
return GET(new NextRequest('https://app.e2b.dev/api/auth/sign-out'))
513+
}
526514

527515
/**
528-
* AUTHENTICATION TEST: Verifies that sign-out redirects to sign-in page with returnTo
516+
* AUTHENTICATION TEST: the plain (non-auth()-wrapped) route handler clears
517+
* the session via the provider and redirects to the sign-in page. Running
518+
* the cookie clear here — rather than inside an auth()-wrapped request — is
519+
* what keeps the deletion from being clobbered by a re-issued JWT cookie.
529520
*/
530-
it('should redirect to sign-in page with returnTo parameter', async () => {
531-
// Setup: Mock Supabase client to return successful sign-out
532-
mockSupabaseClient.auth.signOut.mockResolvedValue({
533-
error: null,
534-
})
521+
it('clears the session and redirects to the sign-in page', async () => {
522+
mockSupabaseClient.auth.signOut.mockResolvedValue({ error: null })
523+
524+
const response = await callSignOutRoute()
535525

536-
// Execute and Verify: Call the sign-out action and expect it to throw redirect
537-
await expect(signOutAction('/dashboard')).rejects.toEqual(
538-
expect.objectContaining({
539-
destination: `${AUTH_URLS.SIGN_IN}?returnTo=${encodeURIComponent('/dashboard')}`,
540-
})
526+
expect(mockSupabaseClient.auth.signOut).toHaveBeenCalled()
527+
expect(response.headers.get('location')).toBe(
528+
`https://app.e2b.dev${AUTH_URLS.SIGN_IN}`
541529
)
542530
})
543531
})

0 commit comments

Comments
 (0)