Skip to content

Commit 52f2309

Browse files
committed
don't rely on /logout and clear ory_session cookie on sign-out
1 parent 9ba7e8e commit 52f2309

5 files changed

Lines changed: 180 additions & 26 deletions

File tree

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

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,44 @@ import 'server-only'
22

33
import { type NextRequest, NextResponse } from 'next/server'
44
import { signOut } from '@/core/server/auth'
5-
import { orySessionCookieDeleteOptions } from '@/core/server/auth/ory/session-cookie'
5+
import {
6+
orySessionCookieDeleteOptions,
7+
resolveSessionCookieDomain,
8+
} from '@/core/server/auth/ory/session-cookie'
69

7-
// Sign-out is a plain route handler. It reads the id_token from e2b_session to
8-
// build Hydra's RP-logout URL, then clears the cookie on the redirect it emits
9-
// (before handing off to Hydra, which ends the Ory + Kratos sessions). The
10-
// client hard-navigates here so the logout overlay stays up until the document
11-
// unloads.
10+
// Ory's identity session cookie: `ory_kratos_session` self-hosted,
11+
// `ory_session_<slug>` on Ory Network. Deliberately excludes Hydra's
12+
// `ory_hydra_session*` cookie, which the RP-logout redirect ends, not us.
13+
const ORY_IDENTITY_SESSION_COOKIE = /^ory_(kratos_)?session/
14+
15+
// Sign-out is a plain route handler the client hard-navigates to, so the logout
16+
// overlay stays up until the document unloads. signOut() revokes the Kratos
17+
// identity session server-side and the redirect ends Hydra's OAuth2 session; we
18+
// drop the browser cookies here — e2b_session (our token cache) and the Ory
19+
// identity cookie, which Hydra's logout leaves in place.
1220
export async function GET(request: NextRequest) {
1321
const { redirectTo } = await signOut({ origin: request.nextUrl.origin })
1422
const response = NextResponse.redirect(
1523
new URL(redirectTo, request.nextUrl.origin)
1624
)
1725
response.cookies.delete(orySessionCookieDeleteOptions(request.nextUrl.host))
26+
clearOryIdentitySessionCookies(request, response)
1827
return response
1928
}
29+
30+
// The cookie name and scope depend on the deployment, so clear whatever Ory
31+
// identity cookie the browser actually sent, scoped the same way it was issued:
32+
// parent-domain on Ory Network (how the @ory/nextjs proxy sets it, e.g.
33+
// `.e2b-staging.dev`), host-only otherwise.
34+
function clearOryIdentitySessionCookies(
35+
request: NextRequest,
36+
response: NextResponse
37+
): void {
38+
const domain = resolveSessionCookieDomain(request.nextUrl.host)
39+
for (const { name } of request.cookies.getAll()) {
40+
if (!ORY_IDENTITY_SESSION_COOKIE.test(name)) continue
41+
response.cookies.delete(
42+
domain ? { name, path: '/', domain } : { name, path: '/' }
43+
)
44+
}
45+
}

src/core/server/auth/ory/kratos-session.ts

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,6 @@ import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
55
import { getOryIdentityApi } from './client'
66
import { readOryError } from './ory-error'
77

8-
/**
9-
* Revokes every Kratos identity session for the given identity.
10-
*
11-
* Hydra's /oauth2/sessions/logout only ends the OAuth2 session; the Kratos
12-
* identity cookie on the Ory domain is independent and is what causes the
13-
* Account Experience to show "Reauthenticate as <last user>" on the next
14-
* sign-in instead of a fresh provider chooser.
15-
*
16-
* We can't surgically target a single session because the OIDC `sid` claim
17-
* from Hydra is Hydra's own OAuth2 session id, not a Kratos session id, and
18-
* we don't have access to the user's Kratos cookie from this side. Revoking
19-
* all identity sessions matches the expected "sign out of identity provider"
20-
* semantics anyway.
21-
*/
228
// Ory uses optimistic locking on identity rows; concurrent writes (e.g. our
239
// admin DELETE racing with Hydra's RP-initiated logout cleanup during the
2410
// same signout flow) return 429 with reason "Conflicting concurrent
@@ -27,12 +13,48 @@ import { readOryError } from './ory-error'
2713
const REVOKE_MAX_ATTEMPTS = 3
2814
const REVOKE_BACKOFF_MS = 150
2915

16+
/**
17+
* Revokes a single Kratos session by its session id (admin DELETE
18+
* /admin/sessions/{id}).
19+
*
20+
* This is the server-side equivalent of the browser self-service logout: it
21+
* ends only the current session, preserving single sign-out semantics. We call
22+
* it on sign-out because Hydra's /oauth2/sessions/logout skips the dashboard
23+
* /logout -> Kratos bridge whenever Hydra holds no active authentication
24+
* session (the production default, where the login is accepted with
25+
* remember=false), which would otherwise leave the Kratos identity session
26+
* alive and surface "Reauthenticate as <last user>" on the next sign-in.
27+
*/
28+
export async function revokeKratosSession(sessionId: string): Promise<void> {
29+
await revokeWithRetries('revoke_kratos_session', () =>
30+
getOryIdentityApi().disableSession({ id: sessionId })
31+
)
32+
}
33+
34+
/**
35+
* Revokes every Kratos identity session for the given identity (admin DELETE
36+
* /admin/identities/{id}/sessions).
37+
*
38+
* Used after a credential change, where signing out every device is the
39+
* intended "sign out of identity provider" behavior. The OIDC `sid` claim from
40+
* Hydra is Hydra's own OAuth2 session id, not a Kratos session id, so
41+
* single-session targeting isn't available on that path.
42+
*/
3043
export async function revokeKratosSessionsForIdentity(
3144
identityId: string
45+
): Promise<void> {
46+
await revokeWithRetries('revoke_kratos_sessions', () =>
47+
getOryIdentityApi().deleteIdentitySessions({ id: identityId })
48+
)
49+
}
50+
51+
async function revokeWithRetries(
52+
operation: string,
53+
run: () => Promise<unknown>
3254
): Promise<void> {
3355
for (let attempt = 1; attempt <= REVOKE_MAX_ATTEMPTS; attempt++) {
3456
try {
35-
await getOryIdentityApi().deleteIdentitySessions({ id: identityId })
57+
await run()
3658
return
3759
} catch (error) {
3860
if (error instanceof ResponseError && error.response.status === 404) {
@@ -53,11 +75,11 @@ export async function revokeKratosSessionsForIdentity(
5375

5476
l.error(
5577
{
56-
key: 'auth_provider:revoke_kratos_sessions:error',
78+
key: `auth_provider:${operation}:error`,
5779
context: { ory: oryDetails, attempt },
5880
error: serializeErrorForLog(error),
5981
},
60-
'failed to revoke Kratos sessions; user may see reauth UX on next sign-in'
82+
'failed to revoke Kratos session(s); user may see reauth UX on next sign-in'
6183
)
6284
return
6385
}

src/core/server/auth/ory/session.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ import {
2222
import { oryAuthFlows } from './flows'
2323
import { isKratosSessionFresh } from './freshness'
2424
import { fromKratosSessionIdentity, fromOryIdentity } from './identity'
25-
import { revokeKratosSessionsForIdentity } from './kratos-session'
25+
import {
26+
revokeKratosSession,
27+
revokeKratosSessionsForIdentity,
28+
} from './kratos-session'
2629
import { revokeOryOAuthSessionsForSubject } from './oauth-session'
2730
import {
2831
E2B_SESSION_COOKIE,
@@ -68,6 +71,19 @@ export async function getUserProfile(): Promise<AuthUser | null> {
6871
export async function signOut(
6972
options?: SignOutOptions
7073
): Promise<SignOutResult> {
74+
// Hydra's RP-initiated logout only runs the /logout -> Kratos bridge when
75+
// Hydra still holds an active authentication session. In production the login
76+
// is accepted with remember=false (non-persistent sessions), so Hydra
77+
// short-circuits to post_logout_redirect_uri and the bridge never fires,
78+
// leaving the Kratos identity session alive. Revoke just this session
79+
// server-side here so sign-out is deterministic across environments without
80+
// signing the user out of their other devices; the redirect below still ends
81+
// Hydra's OAuth2 session.
82+
const sessionId = (await readKratosSession())?.id
83+
if (sessionId) {
84+
await revokeKratosSession(sessionId)
85+
}
86+
7187
return {
7288
redirectTo: await completeOrySignOut(options?.origin),
7389
}

tests/integration/auth-ory-account-security.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const updateIdentityMock = vi.hoisted(() => vi.fn())
77
const patchIdentityMock = vi.hoisted(() => vi.fn())
88
const revokeOAuthSessionsMock = vi.hoisted(() => vi.fn())
99
const revokeKratosSessionsMock = vi.hoisted(() => vi.fn())
10+
const revokeKratosSessionMock = vi.hoisted(() => vi.fn())
1011
const openOrySessionMock = vi.hoisted(() => vi.fn())
1112
const cookieDeleteMock = vi.hoisted(() => vi.fn())
1213

@@ -50,6 +51,7 @@ vi.mock('@/core/server/auth/ory/oauth-session', () => ({
5051

5152
vi.mock('@/core/server/auth/ory/kratos-session', () => ({
5253
revokeKratosSessionsForIdentity: revokeKratosSessionsMock,
54+
revokeKratosSession: revokeKratosSessionMock,
5355
}))
5456

5557
vi.mock('@/core/shared/clients/logger/logger', () => ({
@@ -73,11 +75,14 @@ const currentIdentity = {
7375
function kratosSession({
7476
authenticatedAt = new Date(),
7577
identityId = 'kratos-uuid',
78+
sessionId = 'kratos-session-id',
7679
}: {
7780
authenticatedAt?: Date
7881
identityId?: string
82+
sessionId?: string
7983
} = {}) {
8084
return {
85+
id: sessionId,
8186
active: true,
8287
authenticated_at: authenticatedAt,
8388
identity: {
@@ -96,6 +101,7 @@ describe('Ory account security (Kratos session + e2b_session)', () => {
96101
patchIdentityMock.mockReset().mockResolvedValue(undefined)
97102
revokeOAuthSessionsMock.mockReset().mockResolvedValue(undefined)
98103
revokeKratosSessionsMock.mockReset().mockResolvedValue(undefined)
104+
revokeKratosSessionMock.mockReset().mockResolvedValue(undefined)
99105
openOrySessionMock.mockReset().mockResolvedValue({
100106
accessToken: 'hydra-access-token',
101107
idToken: 'hydra-id-token',
@@ -179,16 +185,32 @@ describe('Ory account security (Kratos session + e2b_session)', () => {
179185
})
180186
})
181187

182-
it('signs out via Hydra RP-logout using the id_token hint', async () => {
188+
it('signs out via Hydra RP-logout and revokes only the current Kratos session', async () => {
189+
getServerSessionMock.mockResolvedValue(kratosSession())
190+
183191
const result = await signOut({ origin: 'https://app.e2b.dev' })
184192

185193
expect(result.redirectTo).toContain(
186194
'https://ory.example.com/oauth2/sessions/logout'
187195
)
188196
expect(result.redirectTo).toContain('id_token_hint=hydra-id-token')
189197
expect(result.redirectTo).toContain('post_logout_redirect_uri=')
190-
// Single sign-out must not revoke every session.
198+
// Revoke this session server-side so logout works even when Hydra skips the
199+
// /logout -> Kratos bridge (no active authentication session in production).
200+
expect(revokeKratosSessionMock).toHaveBeenCalledWith('kratos-session-id')
201+
// ...but single sign-out must not revoke every device's session.
191202
expect(revokeKratosSessionsMock).not.toHaveBeenCalled()
192203
expect(revokeOAuthSessionsMock).not.toHaveBeenCalled()
193204
})
205+
206+
it('still signs out via Hydra RP-logout when no Kratos session is readable', async () => {
207+
getServerSessionMock.mockResolvedValue(undefined)
208+
209+
const result = await signOut({ origin: 'https://app.e2b.dev' })
210+
211+
expect(result.redirectTo).toContain(
212+
'https://ory.example.com/oauth2/sessions/logout'
213+
)
214+
expect(revokeKratosSessionMock).not.toHaveBeenCalled()
215+
})
194216
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { NextRequest } from 'next/server'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const signOutMock = vi.hoisted(() => vi.fn())
5+
6+
vi.mock('@/core/server/auth', () => ({
7+
signOut: signOutMock,
8+
}))
9+
10+
const { GET } = await import('@/app/api/auth/sign-out/route')
11+
12+
// A cleared cookie carries an immediate expiry; Next emits Max-Age=0 and/or an
13+
// epoch Expires depending on version, so accept either.
14+
function clearedCookie(response: Response, name: string): string | undefined {
15+
return response.headers
16+
.getSetCookie()
17+
.find(
18+
(header) =>
19+
header.startsWith(`${name}=`) &&
20+
/(max-age=0|expires=thu, 01 jan 1970)/i.test(header)
21+
)
22+
}
23+
24+
describe('sign-out route cookie clearing', () => {
25+
beforeEach(() => {
26+
signOutMock
27+
.mockReset()
28+
.mockResolvedValue({ redirectTo: 'https://app.e2b.dev/' })
29+
})
30+
31+
afterEach(() => {
32+
vi.unstubAllEnvs()
33+
})
34+
35+
it('clears the Ory Network identity cookie scoped to the parent domain', async () => {
36+
vi.stubEnv('NEXT_PUBLIC_E2B_DOMAIN', 'e2b-staging.dev')
37+
38+
const response = await GET(
39+
new NextRequest('https://e2b-staging.dev/api/auth/sign-out', {
40+
headers: {
41+
cookie:
42+
'ory_session_abcdef=tok; e2b_session=sealed; ory_hydra_session_dev=h',
43+
},
44+
})
45+
)
46+
47+
const oryClear = clearedCookie(response, 'ory_session_abcdef')
48+
expect(oryClear).toBeDefined()
49+
expect(oryClear).toMatch(/Domain=\.e2b-staging\.dev/i)
50+
51+
// Our own token cache is dropped too.
52+
expect(clearedCookie(response, 'e2b_session')).toBeDefined()
53+
// Hydra's session cookie is ended by the RP-logout redirect, not here.
54+
expect(clearedCookie(response, 'ory_hydra_session_dev')).toBeUndefined()
55+
})
56+
57+
it('clears the self-hosted ory_kratos_session cookie host-only', async () => {
58+
const response = await GET(
59+
new NextRequest('http://localhost:3000/api/auth/sign-out', {
60+
headers: { cookie: 'ory_kratos_session=tok' },
61+
})
62+
)
63+
64+
const oryClear = clearedCookie(response, 'ory_kratos_session')
65+
expect(oryClear).toBeDefined()
66+
expect(oryClear).not.toMatch(/Domain=/i)
67+
})
68+
})

0 commit comments

Comments
 (0)