Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/app/api/auth/sign-out/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'server-only'

import { type NextRequest, NextResponse } from 'next/server'
import { auth } from '@/core/server/auth'

// Sign-out lives in a plain route handler, deliberately NOT wrapped by the
// Auth.js `auth()` helper. When sign-out runs inside an auth()-wrapped request,
// the wrapper re-issues a refreshed JWT session cookie at the end of the
// request, which clobbers the session-cookie deletion that signOut() emits and
// leaves the user logged in. Here nothing re-wraps the request, so the deletion
// sticks. The client hard-navigates to this route, so the logout overlay stays
// up until the document unloads (no soft RSC redirect re-rendering the
// signed-out dashboard underneath it).
export async function GET(request: NextRequest) {
const { redirectTo } = await auth.signOut({ origin: request.nextUrl.origin })
return NextResponse.redirect(new URL(redirectTo, request.nextUrl.origin))
}
7 changes: 0 additions & 7 deletions src/core/server/actions/auth-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,13 +384,6 @@ export const forgotPasswordAction = actionClient
}
})

export async function signOutAction(returnTo?: string) {
const origin = (await headers()).get('origin') ?? undefined
const { redirectTo } = await auth.signOut({ origin, returnTo })

throw redirect(redirectTo)
}

// Drives the account-settings re-authentication step and returns the URL the
// client should HARD-navigate to. Supabase signs the user out and bounces
// through /sign-in (which lands back on the account page with ?reauth=1); Ory
Expand Down
9 changes: 2 additions & 7 deletions src/core/server/auth/ory/signout-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,15 @@ import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
import { readOrySessionFields } from './authjs-session-boundary'
import { revokeKratosSessionsForIdentity } from './kratos-session'
import { revokeOryOAuthSessionsForSubject } from './oauth-session'
import { buildOryLogoutUrl, ORY_POST_LOGOUT_PATH } from './signout'
import { ORY_POST_LOGOUT_PATH } from './signout'

export async function completeOrySignOut(origin = BASE_URL): Promise<string> {
const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin)

let idToken: string | undefined
let identityId: string | undefined
let userId: string | undefined
try {
const session = await auth()
const serverFields = readOrySessionFields(session)
userId = session?.user?.id
idToken = serverFields?.idToken
// The Kratos identity id resolved at sign-in — NOT the OIDC subject (which
// is the E2B user id) — so we revoke the right identity's Kratos sessions.
identityId = serverFields?.identityId
Expand Down Expand Up @@ -53,6 +49,5 @@ export async function completeOrySignOut(origin = BASE_URL): Promise<string> {
identityId ? revokeKratosSessionsForIdentity(identityId) : null,
])

const logoutUrl = idToken ? buildOryLogoutUrl({ idToken, origin }) : null
return (logoutUrl ?? postLogoutUrl).toString()
return new URL(ORY_POST_LOGOUT_PATH, origin).toString()
}
15 changes: 7 additions & 8 deletions src/features/dashboard/sidebar/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { usePostHog } from 'posthog-js/react'
import { useState } from 'react'
import { PROTECTED_URLS } from '@/configs/urls'
import { getTeamDisplayName } from '@/core/modules/teams/utils'
import { signOutAction } from '@/core/server/actions/auth-actions'
import { resetOryPostHogIdentity } from '@/features/ory-posthog-identity-bridge'
import { useAppPostHogProvider } from '@/features/posthog-provider'
import { cn } from '@/lib/utils'
Expand Down Expand Up @@ -36,18 +35,18 @@ export default function DashboardSidebarMenu() {
const { enabled: postHogEnabled } = useAppPostHogProvider()
const posthog = usePostHog()
const [createTeamOpen, setCreateTeamOpen] = useState(false)
// explicit state instead of useTransition: a sync transition callback
// settles immediately, so isPending would flip back to false while the
// sign-out action is still in flight; this stays true until the redirect
// navigates away, and only resets if the action fails before that
// Stays true until the hard navigation unloads the page; the overlay should
// never tear down before then.
const [isLoggingOut, setIsLoggingOut] = useState(false)

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

return (
Expand Down
46 changes: 17 additions & 29 deletions tests/integration/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import {
forgotPasswordAction,
signInAction,
signInWithOAuthAction,
signOutAction,
signUpAction,
} from '@/core/server/actions/auth-actions'
import { encodedRedirect } from '@/lib/utils/auth'
Expand Down Expand Up @@ -506,38 +506,26 @@ describe('Auth Actions - Integration Tests', () => {
})
})

describe('Sign Out Flow', () => {
/**
* AUTHENTICATION TEST: Verifies that sign-out redirects to sign-in page
*/
it('should redirect to sign-in page on sign-out', async () => {
// Setup: Mock Supabase client to return successful sign-out
mockSupabaseClient.auth.signOut.mockResolvedValue({
error: null,
})

// Execute and Verify: Call the sign-out action and expect it to throw redirect
await expect(signOutAction()).rejects.toEqual(
expect.objectContaining({
destination: AUTH_URLS.SIGN_IN,
})
)
})
describe('Sign Out Flow (GET /api/auth/sign-out)', () => {
const callSignOutRoute = async () => {
const { GET } = await import('@/app/api/auth/sign-out/route')
return GET(new NextRequest('https://app.e2b.dev/api/auth/sign-out'))
}

/**
* AUTHENTICATION TEST: Verifies that sign-out redirects to sign-in page with returnTo
* AUTHENTICATION TEST: the plain (non-auth()-wrapped) route handler clears
* the session via the provider and redirects to the sign-in page. Running
* the cookie clear here — rather than inside an auth()-wrapped request — is
* what keeps the deletion from being clobbered by a re-issued JWT cookie.
*/
it('should redirect to sign-in page with returnTo parameter', async () => {
// Setup: Mock Supabase client to return successful sign-out
mockSupabaseClient.auth.signOut.mockResolvedValue({
error: null,
})
it('clears the session and redirects to the sign-in page', async () => {
mockSupabaseClient.auth.signOut.mockResolvedValue({ error: null })

const response = await callSignOutRoute()

// Execute and Verify: Call the sign-out action and expect it to throw redirect
await expect(signOutAction('/dashboard')).rejects.toEqual(
expect.objectContaining({
destination: `${AUTH_URLS.SIGN_IN}?returnTo=${encodeURIComponent('/dashboard')}`,
})
expect(mockSupabaseClient.auth.signOut).toHaveBeenCalled()
expect(response.headers.get('location')).toBe(
`https://app.e2b.dev${AUTH_URLS.SIGN_IN}`
)
})
})
Expand Down
Loading