diff --git a/README.md b/README.md index cf2100d..b6cefa9 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,15 @@ Enabling `ensureSignedIn` will redirect users to AuthKit if they attempt to acce Use the `signOut` method to sign out the current logged in user, end the session, and redirect to your app's homepage. The homepage redirect is set in your WorkOS dashboard settings under "Redirect". +If you would like to specify where a user is redirected, an optional `returnTo` argument can be passed. Allowed values are configured in the WorkOS Dashboard under _[Logout redirects](https://workos.com/docs/user-management/sessions/configuring-sessions/logout-redirect)_. + +```ts +export async function action({ request }: ActionFunctionArgs) { + // Called when the form in SignInButton is submitted + return await signOut(request, { returnTo: 'https://example.com' }); +} +``` + ### Get the access token Sometimes it is useful to obtain the access token directly, for instance to make API requests to another service. diff --git a/src/auth.spec.ts b/src/auth.spec.ts index 91af635..e27aea3 100644 --- a/src/auth.spec.ts +++ b/src/auth.spec.ts @@ -61,7 +61,15 @@ describe('auth', () => { const request = new Request('https://example.com'); const response = await signOut(request); expect(response).toBeInstanceOf(Response); - expect(terminateSession).toHaveBeenCalledWith(request); + expect(terminateSession).toHaveBeenCalledWith(request, undefined); + }); + + it('should return a response with returnTo', async () => { + const request = new Request('https://example.com'); + const returnTo = '/dashboard'; + const response = await signOut(request, { returnTo }); + expect(response).toBeInstanceOf(Response); + expect(terminateSession).toHaveBeenCalledWith(request, { returnTo }); }); }); diff --git a/src/auth.ts b/src/auth.ts index 12ace7c..db1bcd1 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -10,8 +10,8 @@ export async function getSignUpUrl(returnPathname?: string) { return getAuthorizationUrl({ returnPathname, screenHint: 'sign-up' }); } -export async function signOut(request: Request) { - return await terminateSession(request); +export async function signOut(request: Request, options?: { returnTo?: string }) { + return await terminateSession(request, options); } /** diff --git a/src/session.spec.ts b/src/session.spec.ts index acef0d9..3074236 100644 --- a/src/session.spec.ts +++ b/src/session.spec.ts @@ -188,6 +188,36 @@ describe('session', () => { expect(getLogoutUrl).not.toHaveBeenCalled(); }); + it('Should redirect to the provided returnTo if no session exists', async () => { + const mockSession = createMockSession({ + has: jest.fn().mockReturnValue(true), + get: jest.fn().mockReturnValue('encrypted-jwt'), + }); + + getSession.mockResolvedValueOnce(mockSession); + + // Mock session data with a token that will decode to no sessionId + const mockSessionData = { + accessToken: 'token.without.sessionid', + refreshToken: 'refresh-token', + user: { id: 'user-id' }, + impersonator: null, + }; + unsealData.mockResolvedValueOnce(mockSessionData); + + // Mock decodeJwt to return no sessionId + (jose.decodeJwt as jest.Mock).mockReturnValueOnce({}); + + const response = await terminateSession(createMockRequest(), { returnTo: '/login' }); + + expect(response instanceof Response).toBe(true); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('/login'); + expect(response.headers.get('Set-Cookie')).toBe('destroyed-session-cookie'); + expect(destroySession).toHaveBeenCalledWith(mockSession); + expect(getLogoutUrl).not.toHaveBeenCalled(); + }); + it('should redirect to WorkOS logout URL when valid session exists', async () => { // Setup a session with jwt const mockSession = createMockSession({ @@ -217,11 +247,13 @@ describe('session', () => { expect(destroySession).toHaveBeenCalledWith(mockSession); expect(getLogoutUrl).toHaveBeenCalledWith({ sessionId: 'test-session-id', + returnTo: undefined, }); expect(mockSession.has).toHaveBeenCalledWith('jwt'); expect(mockSession.get).toHaveBeenCalledWith('jwt'); }); }); + describe('authkitLoader', () => { const createLoaderArgs = (request: Request): LoaderFunctionArgs => ({ request, diff --git a/src/session.ts b/src/session.ts index 17063d5..cb2ecc2 100644 --- a/src/session.ts +++ b/src/session.ts @@ -393,7 +393,7 @@ async function handleAuthLoader( return data({ ...loaderResult, ...auth }, session ? { headers: { ...session.headers } } : undefined); } -export async function terminateSession(request: Request) { +export async function terminateSession(request: Request, { returnTo }: { returnTo?: string } = {}) { const { getSession, destroySession } = await getSessionStorage(); const encryptedSession = await getSession(request.headers.get('Cookie')); const { accessToken } = (await getSessionFromCookie( @@ -408,12 +408,12 @@ export async function terminateSession(request: Request) { }; if (sessionId) { - return redirect(getWorkOS().userManagement.getLogoutUrl({ sessionId }), { + return redirect(getWorkOS().userManagement.getLogoutUrl({ sessionId, returnTo }), { headers, }); } - return redirect('/', { + return redirect(returnTo ?? '/', { headers, }); }