Skip to content

Commit 9ba7e8e

Browse files
committed
fix session logout
1 parent 0be7236 commit 9ba7e8e

4 files changed

Lines changed: 49 additions & 3 deletions

File tree

src/core/server/proxy/classifier.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export function planNeedsAuthGate(plan: ProxyPlan): boolean {
5151
return plan.kind === 'auth-page' || plan.kind === 'dashboard-page'
5252
}
5353

54+
export function isAuthEndpointRoute(pathname: string): boolean {
55+
return matchesAnyPrefix(pathname, AUTH_ENDPOINT_PREFIXES)
56+
}
57+
5458
export function isProxyAuthRoute(pathname: string): boolean {
5559
const normalizedPath = normalizePath(pathname)
5660
return (

src/core/server/proxy/runtime.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
2323
import { getAuthRouteRedirect } from './auth-routes'
2424
import {
2525
classifyProxyRequest,
26+
isAuthEndpointRoute,
2627
type ProxyPlan,
2728
planNeedsAuthGate,
2829
} from './classifier'
@@ -64,11 +65,20 @@ export async function runDashboardProxy(
6465
return oryProxy(request)
6566
}
6667

67-
// Pattern B: refresh the e2b_session up front and propagate it to the same
68+
const plan = classifyProxyRequest(request.nextUrl.pathname)
69+
70+
// refresh the e2b_session up front and propagate it to the same
6871
// request (request.cookies) so RSC/route handlers and the gate below read the
6972
// fresh token, then persist it on the outgoing response for the browser.
70-
const session = await refreshSessionCookie(request)
71-
const plan = classifyProxyRequest(request.nextUrl.pathname)
73+
//
74+
// Auth endpoints own their session lifecycle: sign-out reads the id_token from
75+
// e2b_session before clearing it, the OAuth callback mints a fresh session. A
76+
// dead refresh here would delete the cookie out of the propagated request
77+
// before the handler reads it, breaking RP-initiated logout (Kratos/Hydra
78+
// would never end the session), so skip the refresh for them.
79+
const session = isAuthEndpointRoute(request.nextUrl.pathname)
80+
? skipRefresh
81+
: await refreshSessionCookie(request)
7282

7383
if (!planNeedsAuthGate(plan)) {
7484
return session.persist(await runProxyConcerns(request, plan))
@@ -96,6 +106,8 @@ type SessionRefresh = {
96106

97107
const noPersist: SessionRefresh['persist'] = (response) => response
98108

109+
const skipRefresh: SessionRefresh = { hasToken: false, persist: noPersist }
110+
99111
async function refreshSessionCookie(
100112
request: NextRequest
101113
): Promise<SessionRefresh> {

tests/integration/auth-ory-entrypoints.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,22 @@ describe('Ory auth entrypoints — middleware refresh (Pattern B)', () => {
187187
expect(sealOrySessionMock).not.toHaveBeenCalled()
188188
expect(response.cookies.get('e2b_session')).toBeUndefined()
189189
})
190+
191+
it('skips the refresh for auth endpoints so sign-out keeps its id_token', async () => {
192+
// A dead refresh would otherwise delete e2b_session out of the propagated
193+
// request before the sign-out handler reads the id_token from it, dropping
194+
// RP-initiated logout so Kratos/Hydra never end the session.
195+
refreshOrySessionMock.mockResolvedValue({ status: 'dead' })
196+
197+
const response = await proxy(
198+
request('/api/auth/sign-out'),
199+
{} as NextFetchEvent
200+
)
201+
202+
expect(openOrySessionMock).not.toHaveBeenCalled()
203+
expect(refreshOrySessionMock).not.toHaveBeenCalled()
204+
expect(response.cookies.get('e2b_session')).toBeUndefined()
205+
})
190206
})
191207

192208
describe('Ory OAuth start route', () => {

tests/unit/proxy-plan.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from 'vitest'
22
import {
33
classifyProxyRequest,
4+
isAuthEndpointRoute,
45
planNeedsAuthGate,
56
} from '@/core/server/proxy/classifier'
67

@@ -23,3 +24,16 @@ describe('classifyProxyRequest', () => {
2324
expect(planNeedsAuthGate(plan)).toBe(needsAuthGate)
2425
})
2526
})
27+
28+
describe('isAuthEndpointRoute', () => {
29+
it.each([
30+
['/api/auth/sign-out', true],
31+
['/api/auth/oauth/start', true],
32+
['/api/auth', true],
33+
['/api/health', false],
34+
['/api/trpc/user.update', false],
35+
['/dashboard/team/sandboxes', false],
36+
])('%s -> %s', (pathname, expected) => {
37+
expect(isAuthEndpointRoute(pathname)).toBe(expected)
38+
})
39+
})

0 commit comments

Comments
 (0)