@alexandermo/talent-oauth-pkce
v0.1.1
Published
Универсальный браузерный клиент OAuth2 Authorization Code + PKCE без зависимостей
Downloads
368
Readme
OAuth PKCE для SPA
Библиотека для входа через Талант из браузерного приложения. Открывает страницу авторизации, обрабатывает возврат после входа и поддерживает сессию. Работает с React, Vue и обычным JavaScript, без зависимостей.
Сервер хранит токены в HttpOnly-cookie. Библиотека работает в одном из двух режимов:
mode: 'token'(по умолчанию) — библиотека берётaccess_tokenиз ответа сервера и держит его в памяти. Приложение читает токен черезauth.getAccessToken()и передаёт в свои API-запросы в заголовкеAuthorization: Bearer <token>. Токен не сохраняется вlocalStorageилиsessionStorage. После перезагрузки страницыensureSession()получает новый токен через refresh по cookie.mode: 'cookie'— прежнее поведение: авторизация только через cookie, библиотека токен не читает,getAccessToken()всегда возвращаетundefined.
Собственные запросы библиотеки (проверка, обновление, выход) в обоих режимах идут только с cookie.
Подключение
npm install @alexandermo/talent-oauth-pkceСоздайте один экземпляр на всё приложение, вне компонентов:
// auth.ts
import { createAuth } from '@alexandermo/talent-oauth-pkce';
export const auth = createAuth({
clientId: 'your-client-id',
redirectUri: `${window.location.origin}/auth/complete`,
onError(error) {
// Здесь можно показать сообщение о сбое фонового обновления.
console.warn(error.message);
},
});По умолчанию используется production. Зарегистрируйте redirectUri в настройках OAuth-приложения и добавьте в роутер страницу /auth/complete. Адрес должен быть абсолютным, без #, с теми же протоколом, доменом и портом, что и приложение.
Для работы нужны HTTPS или localhost и доступный sessionStorage: в нём на 10 минут сохраняются данные попытки входа.
Вход, проверка сессии и выход
Ниже — отдельные обработчики приложения. router, showError, loadProfile и clearUser — ваш роутер и функции для работы с интерфейсом и профилем.
import { auth } from './auth';
// Кнопка «Войти».
async function signIn() {
try {
await auth.login({ next: '/games' });
} catch {
showError('Не удалось начать вход. Попробуйте ещё раз.');
}
}
// Страница /auth/complete.
async function completeSignIn() {
try {
const { next } = await auth.completeLogin();
await router.replace(next);
} catch {
showError('Не удалось завершить вход. Начните вход заново.');
}
}
// Перед показом защищённой части приложения.
async function startApp() {
try {
await auth.ensureSession();
await loadProfile();
} catch {
showError('Не удалось загрузить данные. Повторите попытку или войдите заново.');
}
}
// Кнопка «Выйти».
async function signOut() {
try {
await auth.logout();
} catch {
showError('Не удалось завершить выход. Попробуйте ещё раз.');
return;
}
clearUser();
await router.replace('/login');
}next — путь внутри приложения, например /games?tab=drafts; по умолчанию /. Внешние адреса не допускаются. completeLogin() вызывайте только на странице callback. Страницы входа и callback должны открываться без ensureSession().
После входа или успешной проверки библиотека сама обновляет сессию. Профиль, права и переходы между страницами остаются в приложении.
Примеры для React, Vue 2, Vue 3, Axios и fetch
API и настройки
| Метод | Что делает |
| ------------------ | ---------------------------------------------------------------------------------- |
| login({ next? }) | Сохраняет попытку входа и переводит браузер на страницу авторизации. |
| completeLogin() | Обменивает код на сессию и возвращает { next: string }. |
| ensureSession() | Проверяет сессию и при необходимости обновляет её. |
| refresh() | Принудительно обновляет сессию. |
| getAccessToken() | Текущий access_token или undefined (нет токена, истёк, режим 'cookie'). |
| logout() | Завершает серверную сессию. После этого можно снова вызвать login(). |
| destroy() | Останавливает экземпляр, отменяет запросы и убирает таймеры и обработчики событий. |
Все методы, кроме getAccessToken() и destroy(), возвращают Promise. completeLogin() возвращает данные перехода, остальные — Promise<void>. destroy() возвращает void и не завершает серверную сессию. После него нужен новый экземпляр; не вызывайте его при размонтировании отдельного компонента.
В createAuth() обязательны только clientId и redirectUri. Остальные настройки:
| Параметр | Для чего нужен |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| environment | 'production' по умолчанию или 'staging'. |
| apiBaseUrl | Общая база API, только для environment === staging и локальной разработки. нужно чтобы корректно настроить прокси при разработке. Пример ниже. |
| authorizeUrl | Свой абсолютный адрес страницы входа. |
| tokenUrl, introspectUrl, logoutUrl | Отдельные адреса API, абсолютные или относительные. |
| scope | Запрашиваемые права, например 'profile'. |
| refreshHeaders | Дополнительные заголовки только для refresh, например { 'X-Ignore-Access-Token': '1' }. |
| refreshBeforeTokenExpirySeconds | За сколько секунд до истечения обновлять сессию. По умолчанию 15; допустимо конечное число от 0. |
| onError | Обработчик ошибок фоновой проверки и обновления. |
| mode | 'token' по умолчанию (токен доступен через getAccessToken()) или 'cookie' (только cookie, как в прежних версиях). |
| onRemoteLogout | Вызывается, когда выход выполнен в другой вкладке этого приложения; локальная сессия к этому моменту уже сброшена. |
| onAccessTokenChange | Вызывается при каждой смене токена; undefined — токен сброшен. Для UI и стора, не для подстановки в запросы. |
Production использует https://talent.kruzhok.org, staging — https://talent.test.kruzhok.org. Страница входа находится на /authorize, база API — /v2. К базе добавляются пути oauth/issue-token, oauth/introspect и users/me/logout.
Приоритет адресов: отдельный URL → apiBaseUrl → адрес среды. authorizeUrl от базы API не зависит. Настройки задаются при создании экземпляра; только undefined включает значение по умолчанию. Некорректные настройки вызывают AuthError с кодом config_invalid.
apiBaseUrl и локальная разработка
apiBaseUrl можно передать только вместе с environment: 'staging'. Подойдёт полный адрес с префиксом API (https://talent.test.kruzhok.org/v2) или путь от корня сайта (/admin/talent_v2_api). Не добавляйте логин, пароль, query или hash; пустые строки, пробелы, обратные слеши и null не допускаются. Завершающий / необязателен.
Если база уже есть в переменной baseURL, передайте apiBaseUrl: baseURL. Это должен быть именно префикс API Таланта, к которому можно добавить oauth/issue-token, а не адрес другого API приложения.
Для Vite можно заменить auth.ts из первого примера следующим вариантом:
import { createAuth } from '@alexandermo/talent-oauth-pkce';
import type { AuthConfig } from '@alexandermo/talent-oauth-pkce';
const common = {
clientId: 'your-client-id',
redirectUri: `${window.location.origin}/auth/complete`,
};
const config: AuthConfig = import.meta.env.DEV
? {
...common,
environment: 'staging',
apiBaseUrl: '/admin/talent_v2_api',
}
: {
...common,
environment: import.meta.env.VITE_OAUTH_ENVIRONMENT === 'staging' ? 'staging' : 'production',
};
export const auth = createAuth(config);Две ветки сохраняют связь между средой и базой API: в production поле apiBaseUrl отсутствует. Если задать среду и базу двумя независимыми условиями, TypeScript может увидеть запрещённое сочетание production с apiBaseUrl.
Локально этот пример всегда выбирает staging и прокси. Для сборки стенда задайте VITE_OAUTH_ENVIRONMENT=staging; любое другое значение, включая отсутствие переменной, выбирает production. В сборке используются прямые адреса выбранной среды. Сама библиотека переменные Vite не читает.
Добавьте прокси в vite.config.ts:
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
'/admin/talent_v2_api': {
target: 'https://talent.test.kruzhok.org',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/admin\/talent_v2_api/, '/v2'),
cookieDomainRewrite: '',
cookiePathRewrite: '/',
},
},
},
});Так запрос /admin/talent_v2_api/oauth/issue-token попадёт на https://talent.test.kruzhok.org/v2/oauth/issue-token. Прокси убирает Domain у cookie и заменяет явно заданный Path на /; выдача и удаление cookie должны использовать одинаковые Domain/Path. HttpOnly, Secure и SameSite сохраняются. Если cookie не работают по локальному HTTP, используйте HTTPS и проверьте настройки cookie на сервере.
Токен доступа
В режиме 'token' библиотека — единственное место, где хранится токен. Чтобы токен в приложении не расходился с токеном библиотеки:
- читайте
auth.getAccessToken()перед каждым запросом и не сохраняйте копию в сторе,localStorageили замыкании; - на 401 от своего API вызывайте
await auth.refresh()и повторяйте запрос с новымgetAccessToken(). Параллельные вызовыrefresh()объединяются в один запрос, поэтому все повторы получат один и тот же новый токен; onAccessTokenChangeиспользуйте только для реакции интерфейса, например чтобы показать, что пользователь вошёл.
Токен обновляется вместе со сроком сессии: после completeLogin(), refresh() и фонового обновления. Он сбрасывается сразу при вызове logout(), при destroy() и при окончательном отказе обновления. При временном сбое (сеть, 429, 5xx) токен остаётся до истечения срока. getAccessToken() не возвращает истёкший токен. Ответ сервера, пришедший после начала выхода, токен не восстанавливает.
В режиме 'token' ответ issue-token обязан содержать access_token; без него вход и обновление завершаются ошибкой invalid_response. Если сервер его не возвращает, укажите mode: 'cookie'.
Пример — Axios и fetch с заголовком Authorization.
Переход с прежней версии
Раньше библиотека работала только с cookie. Теперь по умолчанию включён режим 'token'. Чтобы сохранить прежнее поведение, добавьте mode: 'cookie' в createAuth().
Ошибки и поведение сессии
Ошибки явных вызовов обрабатывайте через try/catch. onError получает только ошибки фоновых операций. У AuthError есть code, message, необязательный HTTP status и retriable:
import { AuthError } from '@alexandermo/talent-oauth-pkce';
import { auth } from './auth';
try {
await auth.ensureSession();
} catch (error) {
if (error instanceof AuthError && error.retriable) {
showError('Сервер временно недоступен. Повторите попытку позже.');
} else {
showError('Не удалось проверить сессию. Попробуйте войти заново.');
}
}retriable равен true для сетевых ошибок, 429 и 5xx. Такой сбой сам по себе не означает, что пользователь вышел. После временного сбоя фонового обновления библиотека повторяет попытку через 30 секунд; окончательный отказ останавливает обновление.
logout() отправляет POST с cookie и ожидает 204 No Content. Пока выход выполняется, остановите загрузку защищённых данных: новые операции авторизации отклоняются с session_interrupted. Не запускайте новый вход автоматически в ответ на эту ошибку.
Если logout() завершился с ошибкой, покажите пользователю кнопку «Повторить выход», которая снова вызовет logout(). Библиотека сама не повторяет выход и больше не обновляет сессию в фоне. При обрыве связи результат выхода неизвестен: сервер мог выполнить запрос, но ответ не дошёл до браузера.
Если пользователь хочет продолжить работу, вызовите ensureSession(). При успешной проверке автоматическое обновление возобновится. Если сессию восстановить не удалось, предложите повторить проверку или войти заново.
Все OAuth-запросы отправляются с credentials: 'include'. Для прямых запросов на другой домен сервер должен разрешать CORS с cookie, а браузер — отправку этих cookie. В запросах приложения также включайте cookie (credentials: 'include' для fetch или withCredentials: true для Axios), а в режиме 'token' — заголовок Authorization.
Библиотека объединяет параллельные проверки и обновления внутри одного экземпляра. Между вкладками синхронизируется только выход — см. Несколько вкладок. При возврате на вкладку и восстановлении сети ранее установленная сессия перепроверяется, но обработка 401 от вашего API всё равно нужна — примеры.
Несколько вкладок
У каждой вкладки свой экземпляр и свой токен в памяти. Сервер не отзывает access_token при выходе, поэтому без синхронизации другие вкладки могли бы работать со старым токеном до конца его срока.
После успешного logout() (ответ 204) библиотека сообщает о выходе остальным вкладкам того же приложения через BroadcastChannel. Сообщение не содержит токенов, а приложения различаются по clientId и redirectUri. Получив его, вкладка сразу:
- сбрасывает токен, срок сессии и таймер обновления;
- отклоняет свои незавершённые операции с
session_interrupted, чтобы их ответы не восстановили токен; - вызывает
onRemoteLogout.
В onRemoteLogout очистите данные пользователя и перейдите на страницу входа — пример. Если выход завершился ошибкой, другие вкладки не затрагиваются. В браузерах без BroadcastChannel синхронизации нет, остальное работает как обычно.
Это мера для интерфейса, а не защита: уже скопированный токен продолжит действовать до истечения срока. Если сообщение не дошло (вкладка была выгружена браузером), вкладка узнает о выходе при следующем обновлении: оно завершится ошибкой в onError или в catch вашего обработчика 401. Вход в другой вкладке синхронизировать не нужно: ensureSession() получит токен по cookie.
Разработка
Для разработки используйте Node.js 24.16 или новее.
npm ci
npm run typecheck
npm run lint
npm run format:check
npm test
npm run build
npm run smokenpm test запускает тесты Vitest из tests/ без предварительной сборки. Для повторного запуска при изменениях используйте npm run test:watch. Тесты подменяют браузерные API и время.
dist/ создаётся при сборке. npm run smoke проверяет импорт собранного пакета в форматах ESM и CommonJS без браузера. Настоящие редиректы, cookie/CORS, выход и работу нескольких вкладок проверьте в приложении с вашим сервером.
