Skip to content

Commit 602b595

Browse files
committed
fix(auth): clear session in a plain route handler so logout sticks
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.
1 parent a930d4f commit 602b595

5 files changed

Lines changed: 44 additions & 93 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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 — NOT the auth()-wrapped tRPC route.
7+
// The Auth.js route wrapper re-issues a refreshed JWT session cookie at the end
8+
// of every request, which would clobber the session-cookie deletion that
9+
// signOut() emits, leaving the user logged in. Here nothing re-wraps the
10+
// request, so the deletion sticks. The client hard-navigates to this route, so
11+
// the logout overlay stays up until the document unloads (no soft RSC redirect
12+
// re-rendering the signed-out dashboard underneath it).
13+
export async function GET(request: NextRequest) {
14+
const { redirectTo } = await auth.signOut({ origin: request.nextUrl.origin })
15+
return NextResponse.redirect(new URL(redirectTo, request.nextUrl.origin))
16+
}
Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,12 @@
1-
import { z } from 'zod'
21
import { ConfirmEmailInputSchema } from '@/core/modules/auth/models'
3-
import { auth } from '@/core/server/auth'
42
import { verifyOtpAndBuildRedirect } from '@/core/server/auth/verify-otp'
53
import { createTRPCRouter } from '@/core/server/trpc/init'
64
import { publicProcedure } from '@/core/server/trpc/procedures'
7-
import { relativeUrlSchema } from '@/core/shared/schemas/url'
85

96
export const authRouter = createTRPCRouter({
107
verifyOtp: publicProcedure
118
.input(ConfirmEmailInputSchema)
129
.mutation(({ ctx, input }) =>
1310
verifyOtpAndBuildRedirect(input, ctx.requestOrigin)
1411
),
15-
16-
// Returns the URL the client should HARD-navigate to (via window.location)
17-
// rather than redirect()-ing server-side: a soft RSC navigation re-renders the
18-
// signed-out dashboard and tears down the "Logging out..." overlay before the
19-
// browser leaves the page. publicProcedure (no auth guard) keeps sign-out
20-
// resilient even if the session is already gone.
21-
signOut: publicProcedure
22-
.input(
23-
z
24-
.object({
25-
returnTo: relativeUrlSchema.optional(),
26-
})
27-
.optional()
28-
)
29-
.mutation(async ({ ctx, input }) => {
30-
const { redirectTo } = await auth.signOut({
31-
origin: ctx.requestOrigin,
32-
returnTo: input?.returnTo,
33-
})
34-
35-
return { url: redirectTo }
36-
}),
3712
})

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

Lines changed: 5 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,8 @@ 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+
// The admin revocations above already terminate the Kratos + Hydra sessions
53+
// server-side, so we skip the Ory RP-initiated logout round-trip and redirect
54+
// straight to the post-logout page — one redirect, no cross-origin hop.
55+
return new URL(ORY_POST_LOGOUT_PATH, origin).toString()
5856
}

src/features/dashboard/sidebar/menu.tsx

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
'use client'
22

33
import { Portal } from '@radix-ui/react-portal'
4-
import { useMutation } from '@tanstack/react-query'
54
import Link from 'next/link'
65
import { useState } from 'react'
76
import { PROTECTED_URLS } from '@/configs/urls'
87
import { getTeamDisplayName } from '@/core/modules/teams/utils'
98
import { cn } from '@/lib/utils'
10-
import { useTRPC } from '@/trpc/client'
119
import {
1210
DropdownMenu,
1311
DropdownMenuContent,
@@ -31,31 +29,18 @@ import { TeamAvatar } from './team-avatar'
3129

3230
export default function DashboardSidebarMenu() {
3331
const { team } = useDashboard()
34-
const trpc = useTRPC()
3532
const [createTeamOpen, setCreateTeamOpen] = useState(false)
36-
// explicit state instead of the mutation's isPending: isPending flips back to
37-
// false the moment the mutation resolves, which would tear down the overlay a
38-
// beat before the hard navigation unloads the page; this stays true until we
39-
// navigate away, and only resets if the sign-out fails before that
33+
// Stays true until the hard navigation unloads the page; the overlay should
34+
// never tear down before then.
4035
const [isLoggingOut, setIsLoggingOut] = useState(false)
4136

42-
const signOutMutation = useMutation(
43-
trpc.auth.signOut.mutationOptions({
44-
onSuccess: ({ url }) => {
45-
// Hard navigation (not the Next router): a soft RSC redirect re-renders
46-
// the dashboard and tears down this overlay before the browser leaves
47-
// the page. window.location keeps the overlay up until unload.
48-
window.location.href = url
49-
},
50-
onError: () => {
51-
setIsLoggingOut(false)
52-
},
53-
})
54-
)
55-
5637
const handleLogout = () => {
5738
setIsLoggingOut(true)
58-
signOutMutation.mutate({})
39+
// Hard navigation (not the Next router) to a plain route handler that clears
40+
// the session cookie server-side: a soft RSC redirect would re-render the
41+
// signed-out dashboard and tear down this overlay before the browser leaves
42+
// the page. window.location keeps the overlay up until unload.
43+
window.location.href = '/api/auth/sign-out'
5944
}
6045

6146
return (

tests/integration/auth.test.ts

Lines changed: 16 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
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 {
@@ -7,8 +8,6 @@ import {
78
signInWithOAuthAction,
89
signUpAction,
910
} from '@/core/server/actions/auth-actions'
10-
import { authRouter } from '@/core/server/api/routers/auth'
11-
import { createCallerFactory, createTRPCContext } from '@/core/server/trpc/init'
1211
import { encodedRedirect } from '@/lib/utils/auth'
1312

1413
// Create hoisted mock functions that can be used throughout the file
@@ -513,48 +512,26 @@ describe('Auth Actions - Integration Tests', () => {
513512
})
514513
})
515514

516-
describe('Sign Out Flow (trpc auth.signOut)', () => {
517-
const createCaller = createCallerFactory(authRouter)
518-
519-
const getCaller = async () =>
520-
createCaller(
521-
await createTRPCContext({
522-
headers: new Headers(),
523-
requestUrl: 'http://localhost:3000/api/trpc',
524-
})
525-
)
515+
describe('Sign Out Flow (GET /api/auth/sign-out)', () => {
516+
const callSignOutRoute = async () => {
517+
const { GET } = await import('@/app/api/auth/sign-out/route')
518+
return GET(new NextRequest('https://app.e2b.dev/api/auth/sign-out'))
519+
}
526520

527521
/**
528-
* AUTHENTICATION TEST: Verifies that sign-out returns the sign-in page url
522+
* AUTHENTICATION TEST: the plain (non-auth()-wrapped) route handler clears
523+
* the session via the provider and redirects to the sign-in page. Running
524+
* the cookie clear here — rather than inside the auth()-wrapped tRPC route —
525+
* is what keeps the deletion from being clobbered by a re-issued JWT cookie.
529526
*/
530-
it('should return sign-in page url on sign-out', async () => {
531-
// Setup: Mock Supabase client to return successful sign-out
532-
mockSupabaseClient.auth.signOut.mockResolvedValue({
533-
error: null,
534-
})
535-
536-
// Execute and Verify: the mutation returns the URL for the client to hard-navigate to
537-
const caller = await getCaller()
538-
await expect(caller.signOut()).resolves.toEqual({
539-
url: AUTH_URLS.SIGN_IN,
540-
})
541-
})
527+
it('clears the session and redirects to the sign-in page', async () => {
528+
mockSupabaseClient.auth.signOut.mockResolvedValue({ error: null })
542529

543-
/**
544-
* AUTHENTICATION TEST: Verifies that sign-out returns the sign-in url with returnTo
545-
*/
546-
it('should return sign-in page url with returnTo parameter', async () => {
547-
// Setup: Mock Supabase client to return successful sign-out
548-
mockSupabaseClient.auth.signOut.mockResolvedValue({
549-
error: null,
550-
})
530+
const response = await callSignOutRoute()
551531

552-
// Execute and Verify: the mutation returns the URL for the client to hard-navigate to
553-
const caller = await getCaller()
554-
await expect(caller.signOut({ returnTo: '/dashboard' })).resolves.toEqual(
555-
{
556-
url: `${AUTH_URLS.SIGN_IN}?returnTo=${encodeURIComponent('/dashboard')}`,
557-
}
532+
expect(mockSupabaseClient.auth.signOut).toHaveBeenCalled()
533+
expect(response.headers.get('location')).toBe(
534+
`https://app.e2b.dev${AUTH_URLS.SIGN_IN}`
558535
)
559536
})
560537
})

0 commit comments

Comments
 (0)