Azure MSAL та React: Як автоматично оновлювати JWT токени

Сьогодні ми розглянемо, як автоматично оновлювати JWT токени (auth tokens) в React додатку — за допомогою кастомних хуків.

Основний контекст та припущення:
1. Ваш React додаток взаємодіє з API
2. API захищене за допомогою JWT
3. JWT токен має час дії 3 хвилини
4. Потрібно оновлювати JWT токен до того, як він сплине, і передавати його вашому API клієнту (React-Query або Apollo Client), щоб ви могли здійснювати авторизовані запити до вашого сервера
5. Ви входите в додаток користувачів за допомогою Azure MSAL

Проблема:
Коли користувач входить у ваш додаток, він отримує JWT токен вперше. Ви передаєте цей JWT токен у ваш API клієнт для здійснення запитів до сервера — без проблем.

Але ось пройшло 3 хвилини, JWT токен сплив, що робити? Генерувати новий JWT після того, як попередній сплив, чи до того, як він спливе?

Ідеальний результат:
Ідеально, щоб ви вже отримали новий JWT токен і передали його у ваш API клієнт до того, як попередній спливе. Це дозволить вам уникнути переривань у роботі користувачів.

Уявіть, що користувач імпортує 100 тис. рядків даних через завантаження файлу, як би незручно було, якби він побачив помилку “JWT токен сплив” під час завантаження файлу? Не дуже зручно, правда?

Рішення та код:
Пререквізити:
1. Стандартний React проєкт (SPA, бажано створений за допомогою CRA або Vite)
2. Встановіть @azure/msal-react

Кастомний хук — useAuth:

// /src/hooks/use-auth.ts  
import { useEffect, useState } from 'react';  
import { useIsAuthenticated, useMsal } from '@azure/msal-react';  

/**  
 * Скоупи, які ви додаєте тут, будуть запитувати згоду користувача під час входу.  
 * За замовчуванням, MSAL.js додає OIDC скоупи (openid, profile, email) до кожного запиту на вхід.  
 * Для більш детальної інформації про OIDC скоупи, відвідайте:  
 * https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes  
 */  
export const silentRequest: SilentRequest = {  
 scopes: [  
 `https://${env.B2C_TENANT_NAME}.onmicrosoft.com/api/Client.Read`,  
 `https://${env.B2C_TENANT_NAME}.onmicrosoft.com/api/Client.Write`,  
 ],  
};  

export type Nullable = null | undefined | T;  

export type UseAuthResponse = {  
 /** Новий або оновлений JWT токен залогіненого користувача, витягнутий з MSAL */  
 accessToken: Nullable;  

 /**  
 * Показує, чи користувач авторизований через MSAL  
 *  
 * Тільки тому, що користувач авторизований через MSAL,  
 * не означає, що він є авторизованим користувачем цього додатку.  
 *  
 * Для цього веб-додатку ми зробили так, щоб тільки користувачі, які  
 * є в нашій власній базі даних, мали доступ до цього додатку.  
 * Ми зробили це, щоб лише певні користувачі могли використовувати систему, що відповідає принципам мінімальних прав.  
 */  
 isAuthenticated: boolean;  

 /** Адреса електронної пошти залогіненого користувача, витягнута з MSAL */  
 userEmail: Nullable;  

 /**  
 * Показує кількість секунд до того, як JWT токен спливе  
 *  
 * Корисно, коли вам потрібно відобразити модальне вікно, щоб сповістити користувачів, що вони будуть виведені з системи за, наприклад, 59 секунд  
 */  
 tokenExpiresInXSeconds: Nullable;  
};  

/**  
 * Відповідає за:  
 * 1. Витягування JWT токену для залогіненого користувача  
 * 2. Витягування email користувача з JWT токену  
 * 3. Автоматичне та нескінченне оновлення JWT токену для повернення  
 * 4. Розрахунок часу до того, як JWT токен спливе  
 *  
 * Переконайтеся, що компонент/хук, до якого ви викликаєте цей кастомний хук, відображається ВНУТРІШНЬО  
 * `` з `@azure/msal-react`. 

Це тому, що `useMsal()` можна викликати тільки  
 * всередині ``  
 */  
export const useAuth = (): UseAuthResponse => {  
 const { instance, accounts } = useMsal();  
 const isAuthenticated = useIsAuthenticated();  

 const [response, setResponse] = useState({  
 accessToken: null,  
 isAuthenticated: false,  
 userEmail: null,  
 tokenExpiresInXSeconds: 0,  
 });  

 useEffect(() => {  
 let timeoutId: NodeJS.Timeout;  
 let intervalId: NodeJS.Timeout;  

 const refreshToken = async () => {  
 try {  
 console.log('Оновлення токену...');  
 const res = await instance.acquireTokenSilent({ ...silentRequest });  
 console.log('Токен оновлено');  

 let expiresInMs = res?.expiresOn ? res?.expiresOn?.getTime() - Date.now() : 0;  

 setResponse((prev) => ({  
 ...prev,  
 isAuthenticated: true,  
 accessToken: res?.accessToken,  
 userEmail: res?.account?.username,  
 }));  

 // Оновлюємо токен на півшляху до його закінчення  
 const refreshTime = expiresInMs ? Math.max(0, expiresInMs / 2) : 0;  
 timeoutId = setTimeout(refreshToken, refreshTime);  

 // Очищаємо будь-який існуючий інтервал  
 if (intervalId) {  
 clearInterval(intervalId);  
 }  

 // Оновлюємо кількість секунд до закінчення токену кожну секунду  
 intervalId = setInterval(() => {  
 if (expiresInMs > 0) {  
 expiresInMs -= 1000; // ВАЖЛИВО - оновлюємо кількість секунд до закінчення токену  
 const tokenExpiresInXSeconds = Math.round(expiresInMs / 1000);  
 setResponse((prev) => ({  
 ...prev,  
 tokenExpiresInXSeconds,  
 }));  
 return; // ПОТРІБНО ПОВЕРНУТИСЯ ТУТ, щоб випадково не очистити інтервал  
 }  
 clearInterval(intervalId);  
 }, 1000);  
 } catch (error) {  
 // Обробка помилки, якщо потрібно  
 console.error('Оновлення токену не вдалося:', error);  
 }  
 };  

 if (isAuthenticated && accounts?.length > 0) {  
 refreshToken();  
 }  

 return () => {  
 if (timeoutId) {  
 clearTimeout(timeoutId);  
 }  
 if (intervalId) {  
 clearInterval(intervalId);  
 }  
 };  
 }, [instance, accounts, isAuthenticated]);  

 return response;  
};

Як використовувати кастомний хук — Apollo Client:

// App.tsx  
import { ChakraProvider } from '@chakra-ui/react';  
import { RouterProvider } from '@tanstack/react-router';  
import { AuthenticatedTemplate, UnauthenticatedTemplate } from '@azure/msal-react';  
import { ApolloProvider } from '@apollo/client';  
import { useAuth } from '@/hooks/use-auth';  
import { useApolloClientWithAuth } from '@/hooks/use-apollo-client-with-auth';  
import { theme } from '@/styles/theme';  
import { LoginPage } from '@/components/login-page';  
import { env } from '@/utils/env';  

export const App = () => {  
 const authContext = useAuth();  
 const apolloClient = useApolloClientWithAuth({  
 apiUrl: `${env.API_URL}/api/graphql`,  
 accessToken: authContext?.accessToken ?? '',  
 });  

 return (  






 {/* замініть AuthenticatedApp на ваш компонент для авторизованого додатку */}  




 );  
};  

// /src/hooks/use-apollo-client-with-auth.ts  
import {  
 ApolloClient,  
 FieldPolicy,  
 FieldReadFunction,  
 InMemoryCache,  
 NormalizedCacheObject,  
 createHttpLink,  
} from '@apollo/client';  
import { setContext } from '@apollo/client/link/context';  

const cache = new InMemoryCache();  

export type UseApolloClientWithAuthArgs = {  
 /** URL призначення API, наприклад, `https://deployed-backend.com/api/graphql` */  
 apiUrl: string;  

 /** JWT токен для здійснення API запитів.  

Переконайтеся, що він містить тільки сам JWT, без додавання `Bearer ` на початку */  
 accessToken: string;  
};  
/**  
 * Створює екземпляр Apollo Client, який здійснює API запити до  
 * кінцевої точки `X`, використовуючи токен доступу `Y` в заголовку запиту  
 */  
export const useApolloClientWithAuth = (args: UseApolloClientWithAuthArgs): ApolloClient => {  
 const { apiUrl, accessToken } = args;  

 const authLink = setContext((_, { headers }) => {  
 return {  
 headers: {  
 ...headers,  
 Authorization: `Bearer ${accessToken}`,  
 },  
 };  
 });  

 const httpLink = createHttpLink({  
 uri: apiUrl,  
 });  

 const link = authLink.concat(httpLink);  
 return new ApolloClient({ cache, link });  
};  

// src/main.tsx  
import { StrictMode } from 'react';  
import { createRoot } from 'react-dom/client';  
import { MsalProvider } from '@azure/msal-react';  
import { createRouter } from '@tanstack/react-router';  
import { App } from '@/App';  

// оголосіть ваш екземпляр MSAL  
const msalInstance = {};  

createRoot(document.getElementById('root')!).render(  




 ,  
);

Пояснення:

Кожного разу, коли ви викликаєте useAuth() всередині `і отримуєтеaccessToken`, ви отримуєте найактуальніший JWT токен від MSAL. Вам потрібно лише передати його один раз до Apollo Client, і він матиме найактуальніший JWT токен щоразу, коли буде здійснювати запити до вашого GraphQL API.

Та сама логіка застосовується і до React-Query. Все, що потрібно, це викликати useAuth і передати accessToken у клієнт запитів, і ви зможете здійснювати авторизовані API запити до вашого сервера.

Висновок:

Автоматичне оновлення JWT і його заповнення в вашому React додатку — це не надскладне завдання. Ключ до цього полягає в налаштуванні логіки так, щоб новий JWT токен був отриманий відразу перед тим, як попередній токен спливе, а потім його слід передати. Ідеальний спосіб реалізувати логіку оновлення токену — це використання кастомного хука, оскільки це дозволяє використовувати найактуальніший JWT токен у будь-якому місці вашого додатку без необхідності знову і знову переписувати одну й ту саму логіку.

Якщо ви вважаєте це корисним, поділіться цією інформацією з вашою командою і дайте знати, що б ви хотіли побачити далі.

🤜🤛

Дякуємо, що є частиною спільноти

Перш ніж піти:

Перекладено з: Azure MSAL & React: How to Automatically Update JWT Tokens

Leave a Reply

Your email address will not be published. Required fields are marked *