Skip to content

Commit 6fd623e

Browse files
committed
domain-wise cookie
1 parent e2b861e commit 6fd623e

8 files changed

Lines changed: 132 additions & 11 deletions

File tree

src/app/api/auth/oauth/callback/ory/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,11 @@ export async function GET(request: NextRequest) {
9595

9696
const destination = flow.returnTo ?? PROTECTED_URLS.DASHBOARD
9797
const response = finalize(NextResponse.redirect(new URL(destination, origin)))
98-
response.cookies.set(E2B_SESSION_COOKIE, sealed, orySessionCookieOptions())
98+
response.cookies.set(
99+
E2B_SESSION_COOKIE,
100+
sealed,
101+
orySessionCookieOptions(request.nextUrl.host)
102+
)
99103
return response
100104
}
101105

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

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

33
import { type NextRequest, NextResponse } from 'next/server'
44
import { signOut } from '@/core/server/auth'
5-
import { E2B_SESSION_COOKIE } from '@/core/server/auth/ory/session-cookie'
5+
import { orySessionCookieDeleteOptions } from '@/core/server/auth/ory/session-cookie'
66

77
// Sign-out is a plain route handler. It reads the id_token from e2b_session to
88
// build Hydra's RP-logout URL, then clears the cookie on the redirect it emits
@@ -14,6 +14,6 @@ export async function GET(request: NextRequest) {
1414
const response = NextResponse.redirect(
1515
new URL(redirectTo, request.nextUrl.origin)
1616
)
17-
response.cookies.delete(E2B_SESSION_COOKIE)
17+
response.cookies.delete(orySessionCookieDeleteOptions(request.nextUrl.host))
1818
return response
1919
}

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export type OrySessionCookieOptions = {
2929
path: '/'
3030
secure: boolean
3131
maxAge: number
32+
domain?: string
33+
}
34+
35+
export type OrySessionCookieDeleteOptions = {
36+
name: typeof E2B_SESSION_COOKIE
37+
path: '/'
38+
domain?: string
3239
}
3340

3441
// Cache the derived key per secret value so rotating E2B_SESSION_SECRET (and
@@ -78,7 +85,9 @@ export async function openOrySession(
7885
}
7986
}
8087

81-
export function orySessionCookieOptions(): OrySessionCookieOptions {
88+
export function orySessionCookieOptions(
89+
host?: string | null
90+
): OrySessionCookieOptions {
8291
return {
8392
httpOnly: true,
8493
sameSite: 'lax',
@@ -87,7 +96,39 @@ export function orySessionCookieOptions(): OrySessionCookieOptions {
8796
// and serve over HTTPS; local `next dev` is plain-HTTP loopback.
8897
secure: process.env.NODE_ENV === 'production',
8998
maxAge: SESSION_COOKIE_MAX_AGE_SECONDS,
99+
domain: resolveSessionCookieDomain(host),
100+
}
101+
}
102+
103+
// Deleting a domain-scoped cookie requires the same domain attribute, so the
104+
// clear paths must pass these options rather than the bare cookie name.
105+
export function orySessionCookieDeleteOptions(
106+
host?: string | null
107+
): OrySessionCookieDeleteOptions {
108+
return {
109+
name: E2B_SESSION_COOKIE,
110+
path: '/',
111+
domain: resolveSessionCookieDomain(host),
112+
}
113+
}
114+
115+
// Scope the cookie to the parent domain (e.g. `.e2b-staging.dev`) so it is
116+
// shared across every subdomain of the deployment environment instead of being
117+
// pinned to the exact host. Hosts that don't belong to NEXT_PUBLIC_E2B_DOMAIN
118+
// (localhost, Vercel preview URLs) get a host-only cookie — a `.dev` domain
119+
// attribute there would be rejected by the browser.
120+
export function resolveSessionCookieDomain(
121+
host: string | null | undefined
122+
): string | undefined {
123+
const base = process.env.NEXT_PUBLIC_E2B_DOMAIN
124+
if (!base || !host) return undefined
125+
126+
const hostname = host.split(':')[0] ?? host
127+
if (hostname === base || hostname.endsWith(`.${base}`)) {
128+
return `.${base}`
90129
}
130+
131+
return undefined
91132
}
92133

93134
function parseTokens(

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'server-only'
22

33
import { getServerSession } from '@ory/nextjs/app'
4-
import { cookies } from 'next/headers'
4+
import { cookies, headers } from 'next/headers'
55
import { cache } from 'react'
66
import { PROTECTED_URLS } from '@/configs/urls'
77
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
@@ -24,7 +24,11 @@ import { isKratosSessionFresh } from './freshness'
2424
import { fromKratosSessionIdentity, fromOryIdentity } from './identity'
2525
import { revokeKratosSessionsForIdentity } from './kratos-session'
2626
import { revokeOryOAuthSessionsForSubject } from './oauth-session'
27-
import { E2B_SESSION_COOKIE, openOrySession } from './session-cookie'
27+
import {
28+
E2B_SESSION_COOKIE,
29+
openOrySession,
30+
orySessionCookieDeleteOptions,
31+
} from './session-cookie'
2832
import { completeOrySignOut } from './signout-flow'
2933

3034
const ACCOUNT_SETTINGS_REAUTH_RETURN_TO = `${PROTECTED_URLS.ACCOUNT_SETTINGS}?reauth=1`
@@ -141,8 +145,8 @@ const readOrySessionTokens = cache(async () => {
141145

142146
async function clearOrySessionCookie(): Promise<void> {
143147
try {
144-
const cookieStore = await cookies()
145-
cookieStore.delete(E2B_SESSION_COOKIE)
148+
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])
149+
cookieStore.delete(orySessionCookieDeleteOptions(headerStore.get('host')))
146150
} catch (error) {
147151
l.warn(
148152
{

src/core/server/proxy/runtime.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { isKratosSessionActive } from '@/core/server/auth/ory/kratos-session-edg
1111
import {
1212
E2B_SESSION_COOKIE,
1313
openOrySession,
14+
orySessionCookieDeleteOptions,
1415
orySessionCookieOptions,
1516
sealOrySession,
1617
} from '@/core/server/auth/ory/session-cookie'
@@ -119,7 +120,7 @@ async function refreshSessionCookie(
119120
response.cookies.set(
120121
E2B_SESSION_COOKIE,
121122
sealed,
122-
orySessionCookieOptions()
123+
orySessionCookieOptions(request.nextUrl.host)
123124
)
124125
}
125126
return response
@@ -135,7 +136,9 @@ async function refreshSessionCookie(
135136
hasToken: false,
136137
persist: (response) => {
137138
if (response instanceof NextResponse) {
138-
response.cookies.delete(E2B_SESSION_COOKIE)
139+
response.cookies.delete(
140+
orySessionCookieDeleteOptions(request.nextUrl.host)
141+
)
139142
}
140143
return response
141144
},

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,20 @@ vi.mock('next/headers', () => ({
2020
get: vi.fn(() => ({ value: 'sealed-cookie' })),
2121
delete: cookieDeleteMock,
2222
}),
23+
headers: () =>
24+
Promise.resolve({
25+
get: vi.fn(() => 'app.e2b.dev'),
26+
}),
2327
}))
2428

2529
vi.mock('@/core/server/auth/ory/session-cookie', () => ({
2630
E2B_SESSION_COOKIE: 'e2b_session',
2731
openOrySession: openOrySessionMock,
32+
orySessionCookieDeleteOptions: (host: string | null | undefined) => ({
33+
name: 'e2b_session',
34+
path: '/',
35+
domain: host ? `.${host}` : undefined,
36+
}),
2837
}))
2938

3039
vi.mock('@/core/server/auth/ory/client', () => ({
@@ -163,7 +172,11 @@ describe('Ory account security (Kratos session + e2b_session)', () => {
163172

164173
expect(revokeOAuthSessionsMock).toHaveBeenCalledWith('kratos-uuid')
165174
expect(revokeKratosSessionsMock).toHaveBeenCalledWith('kratos-uuid')
166-
expect(cookieDeleteMock).toHaveBeenCalledWith('e2b_session')
175+
expect(cookieDeleteMock).toHaveBeenCalledWith({
176+
name: 'e2b_session',
177+
path: '/',
178+
domain: '.app.e2b.dev',
179+
})
167180
})
168181

169182
it('signs out via Hydra RP-logout using the id_token hint', async () => {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ vi.mock('@/core/server/auth/ory/session-cookie', () => ({
2323
openOrySession: openOrySessionMock,
2424
sealOrySession: sealOrySessionMock,
2525
orySessionCookieOptions: () => ({ httpOnly: true, path: '/' }),
26+
orySessionCookieDeleteOptions: () => ({ name: 'e2b_session', path: '/' }),
2627
}))
2728

2829
vi.mock('@/core/server/auth/ory/token-refresh', () => ({

tests/unit/session-cookie.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22
import {
33
type OrySessionTokens,
44
openOrySession,
5+
orySessionCookieDeleteOptions,
56
orySessionCookieOptions,
7+
resolveSessionCookieDomain,
68
sealOrySession,
79
} from '@/core/server/auth/ory/session-cookie'
810

@@ -79,3 +81,56 @@ describe('e2b_session cookie', () => {
7981
expect(orySessionCookieOptions().secure).toBe(false)
8082
})
8183
})
84+
85+
describe('e2b_session cookie domain', () => {
86+
beforeEach(() => {
87+
vi.stubEnv('NEXT_PUBLIC_E2B_DOMAIN', 'e2b-staging.dev')
88+
})
89+
90+
afterEach(() => {
91+
vi.unstubAllEnvs()
92+
})
93+
94+
it('scopes a subdomain host to the parent domain', () => {
95+
expect(resolveSessionCookieDomain('dashboard.e2b-staging.dev')).toBe(
96+
'.e2b-staging.dev'
97+
)
98+
})
99+
100+
it('scopes the apex host to the parent domain', () => {
101+
expect(resolveSessionCookieDomain('e2b-staging.dev')).toBe(
102+
'.e2b-staging.dev'
103+
)
104+
})
105+
106+
it('ignores the port when matching', () => {
107+
expect(resolveSessionCookieDomain('e2b-staging.dev:3000')).toBe(
108+
'.e2b-staging.dev'
109+
)
110+
})
111+
112+
it('returns no domain for unrelated hosts (localhost, previews)', () => {
113+
expect(resolveSessionCookieDomain('localhost')).toBeUndefined()
114+
expect(resolveSessionCookieDomain('preview.vercel.app')).toBeUndefined()
115+
// A suffix that is not a domain boundary must not match.
116+
expect(resolveSessionCookieDomain('evil-e2b-staging.dev')).toBeUndefined()
117+
})
118+
119+
it('returns no domain when the env is unset', () => {
120+
vi.stubEnv('NEXT_PUBLIC_E2B_DOMAIN', '')
121+
expect(
122+
resolveSessionCookieDomain('dashboard.e2b-staging.dev')
123+
).toBeUndefined()
124+
})
125+
126+
it('flows the resolved domain into set and delete options', () => {
127+
expect(orySessionCookieOptions('app.e2b-staging.dev').domain).toBe(
128+
'.e2b-staging.dev'
129+
)
130+
expect(orySessionCookieDeleteOptions('app.e2b-staging.dev')).toEqual({
131+
name: 'e2b_session',
132+
path: '/',
133+
domain: '.e2b-staging.dev',
134+
})
135+
})
136+
})

0 commit comments

Comments
 (0)