Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(chat): Implement federated logout with custom session termination flow #2079

Merged
merged 8 commits into from
Sep 12, 2024
45 changes: 28 additions & 17 deletions apps/chat-e2e/src/tests/announcementBanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,29 @@ dialTest(
'Banner is shown.\n' +
'Banner text contains html link.\n' +
"Banner doesn't appear if to close it",
async ({
dialHomePage,
conversationData,
dataInjector,
chatBar,
promptBar,
conversations,
banner,
header,
appContainer,
chatMessages,
accountSettings,
accountDropdownMenu,
confirmationDialog,
providerLogin,
setTestIds,
}) => {
async (
{
dialHomePage,
conversationData,
dataInjector,
chatBar,
promptBar,
conversations,
banner,
header,
appContainer,
chatMessages,
accountSettings,
accountDropdownMenu,
confirmationDialog,
providerLogin,
setTestIds,
},
testInfo,
) => {
setTestIds('EPMRTC-1576', 'EPMRTC-1580', 'EPMRTC-1577');
const username =
process.env.E2E_USERNAME!.split(',')[+process.env.TEST_PARALLEL_INDEX!];
let conversation: Conversation;
let chatBarBounding;
let promptBarBounding;
Expand Down Expand Up @@ -148,6 +153,12 @@ dialTest(
await accountDropdownMenu.selectMenuOption(AccountMenuOptions.logout);
await confirmationDialog.confirm();
await providerLogin.navigateToCredentialsPage();
await providerLogin.authProviderLogin(
testInfo,
username,
process.env.E2E_PASSWORD!,
false,
);
await chatMessages.waitForState({ state: 'attached' });
await expect
.soft(banner.getElementLocator(), ExpectedMessages.bannerIsClosed)
Expand Down
2 changes: 1 addition & 1 deletion apps/chat-e2e/src/ui/actions/providerLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export abstract class ProviderLogin<T extends BasePage & LoginInterface> {
: await this.loginPage.navigateToBaseUrl();
}

protected async authProviderLogin(
public async authProviderLogin(
testInfo: TestInfo,
username: string,
password: string,
Expand Down
8 changes: 4 additions & 4 deletions apps/chat/src/components/Header/User/UserDesktop.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/*eslint-disable @next/next/no-img-element*/
import { IconSettings } from '@tabler/icons-react';
import { signIn, signOut, useSession } from 'next-auth/react';
import { signIn, useSession } from 'next-auth/react';
import { useCallback, useState } from 'react';

import { useTranslation } from 'next-i18next';

import { customSignOut } from '@/src/utils/auth/signOut';

import { Translation } from '@/src/types/translation';

import { useAppDispatch } from '@/src/store/hooks';
Expand All @@ -25,9 +27,7 @@ export const UserDesktop = () => {
const { data: session } = useSession();
const dispatch = useAppDispatch();
const handleLogout = useCallback(() => {
session
? signOut({ redirect: true })
: signIn('azure-ad', { redirect: true });
session ? customSignOut() : signIn('azure-ad', { redirect: true });
}, [session]);

return (
Expand Down
8 changes: 4 additions & 4 deletions apps/chat/src/components/Header/User/UserMobile.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/*eslint-disable @next/next/no-img-element*/
import { IconSettings } from '@tabler/icons-react';
import { signIn, signOut, useSession } from 'next-auth/react';
import { signIn, useSession } from 'next-auth/react';
import { useCallback, useState } from 'react';

import { useTranslation } from 'next-i18next';

import classNames from 'classnames';

import { customSignOut } from '@/src/utils/auth/signOut';

import { Translation } from '@/src/types/translation';

import { useAppDispatch, useAppSelector } from '@/src/store/hooks';
Expand Down Expand Up @@ -71,9 +73,7 @@ const Logout = () => {
useState(false);

const handleLogout = useCallback(() => {
session
? signOut({ redirect: true })
: signIn('azure-ad', { redirect: true });
session ? customSignOut() : signIn('azure-ad', { redirect: true });
}, [session]);
return (
<>
Expand Down
64 changes: 64 additions & 0 deletions apps/chat/src/pages/api/auth/federated-logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { getToken } from 'next-auth/jwt';

import NextClient from '@/src/utils/auth/nextauth-client';
import { logger } from '@/src/utils/server/logger';

const DEFAULT_LOGOUT_REDIRECT_URI =
process.env.NEXTAUTH_URL || 'http://localhost:3000/';

/**
* Federated logout handler
*
* 1. Retrieves the user's authentication token via JWT.
* 2. Validates the presence and type of `providerId` in the token.
* 3. If the provider supports logout, generates a URL for session termination.
* 4. Returns the logout URL in JSON format or `null` if logout is not possible.
*
* @param req - HTTP request (NextApiRequest)
* @param res - HTTP response (NextApiResponse)
*
* @returns A JSON response with the logout URL or `null` in case of an error.
*/
const handler = async (
req: NextApiRequest,
res: NextApiResponse,
): Promise<void> => {
try {
const token = await getToken({ req });

if (!token || typeof token.providerId !== 'string') {
logger.warn('Token is missing or providerId not found.');
res.status(200).json({ url: null });
return;
}

const client = NextClient.getClient(token.providerId);

if (!client) {
logger.warn(`Client for providerId ${token.providerId} not found.`);
res.status(200).json({ url: null });
return;
}

const url = client.endSessionUrl({
post_logout_redirect_uri: DEFAULT_LOGOUT_REDIRECT_URI,
id_token_hint: token.idToken as string,
});

if (!url) {
logger.warn(
`End session URL not found for providerId ${token.providerId}.`,
);
res.status(200).json({ url: null });
return;
}

res.status(200).json({ url });
} catch (error) {
logger.error('Error during federated logout:', error);
res.status(200).json({ url: null });
}
};

export default handler;
1 change: 1 addition & 0 deletions apps/chat/src/utils/auth/auth-callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export const callbacks: Partial<
refreshToken: options.account.refresh_token,
providerId: options.account.provider,
userId: options.user.id,
idToken: options.account.id_token,
};
}

Expand Down
27 changes: 27 additions & 0 deletions apps/chat/src/utils/auth/signOut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { signOut } from 'next-auth/react';

import { parseUrl } from 'next/dist/shared/lib/router/utils/parse-url';

/**
* Custom signOut function to handle federated logout.
* - It first removes the session cookie using next-auth's signOut method.
* - Then, it checks for a federated logout URL by calling the backend API.
* - If a federated logout URL is returned, it redirects the user to the external identity provider for logout.
*
* @returns {Promise<void>}
*/
export const customSignOut = async (): Promise<void> => {
try {
const res = await fetch('/api/auth/federated-logout');
const { url }: { url: string | null } = await res.json();

await signOut({ redirect: true });

if (url) {
const parsedUrl = parseUrl(url);
window.location.href = parsedUrl.href;
}
} catch (error) {
await signOut({ redirect: true });
}
};