@uzum-tech/node-keycloak
v1.1.0
Published
Framework-agnostic Keycloak client for Node: sign in, sign out, token cache with auto refresh and JWKS token verification
Downloads
64
Maintainers
Readme
@uzum-tech/node-keycloak
Клиент Keycloak для Node.js без привязки к фреймворку. Вход, выход, хранение токена в кеше, автоматический рефреш и проверка подписи токена через JWKS — в одном инстансе.
Содержание
- Зачем
- Установка
- Быстрый старт
- Конфигурация
- API инстанса
- Как работает кеш и авторефреш
- Проверка подписи через JWKS
- Ошибки
- HTTP-клиент с автоматической авторизацией
- Рецепты
- FAQ
Зачем
В каждом сервисе повторялся один и тот же код: сходить в Keycloak за токеном, положить его в кеш, вовремя обновить, а входящие токены проверить по сертификату. Пакет собирает это в один объект.
Что внутри:
- вход —
passwordgrant с логином/паролем и client credentials; - выход — вызов
logoutи очистка кеша; - хранение токена — LRU-кеш, ключ считается по конфигу инстанса;
- авторефреш —
getToken()сам решает: отдать из кеша, обновить по refresh-токену или залогиниться заново; - проверка подписи — через JWKS-эндпоинт реалма или по переданному контенту сертификата, без сети;
- Basic-режим — когда сервису нужен не OAuth, а обычный
Authorization: Basic.
Пакет не зависит ни от NestJS, ни от Fastify, ни от Express. Вы сами решаете, где вызывать его методы: в guard, в middleware, в cron-задаче или в обычной функции.
Установка
pnpm add @uzum-tech/node-keycloakТребуется Node.js >= 20.18.1.
Пакет собран в двух форматах, поэтому работает и в CommonJS-проектах
(NestJS с "module": "commonjs"), и в ESM:
// ESM
import NodeKeycloak from '@uzum-tech/node-keycloak';
// CommonJS
const NodeKeycloak = require('@uzum-tech/node-keycloak').default;Быстрый старт
import NodeKeycloak from '@uzum-tech/node-keycloak';
export const keycloak = new NodeKeycloak({
auth: {
url: process.env.API_KC_AUTH_URL!,
realm: 'auth',
username: process.env.API_KC_LOGIN!,
password: process.env.API_KC_PASSWORD!,
clientId: process.env.API_KC_CLIENT!,
clientSecret: process.env.API_KC_SECRET!
}
});Дальше — три главных вызова:
// 1. Готовый заголовок для исходящего запроса.
// Сам залогинится, сам обновит, сам отдаст из кеша.
const authorization = await keycloak.getAuthHeader();
// -> 'Bearer eyJhbGciOi...'
// 2. Полный ответ Keycloak, если нужны refresh_token и сроки жизни.
const token = await keycloak.getToken();
// 3. Проверка входящего токена по подписи.
const payload = await keycloak.verify(incomingToken);
// -> { preferred_username: 'admin', realm_access: { roles: [...] }, ... }Инстанс создаётся один раз на приложение и переиспользуется — именно он хранит кеш токена.
Обе секции конфига опциональны: если сервису нужна только проверка
входящих токенов, секцию auth можно не передавать — см.
Только проверка токенов.
Конфигурация
Две секции: auth и jwks
Конфиг состоит из двух секций, и обе опциональны:
new NodeKeycloak({
auth: { /* поход в Keycloak за сервисным токеном */ },
jwks: { /* проверка подписи входящих токенов */ }
});| Секция | Обязательна | За что отвечает | Что будет без неё |
|---|---|---|---|
| auth | нет | Логин в Keycloak, токены, кеш | Токенные методы бросают AUTH_NOT_CONFIGURED, verify() продолжает работать |
| jwks | нет | Проверка подписи входящих токенов | Ключи берутся с эндпоинта реалма из auth с умолчаниями |
Хотя бы одна секция должна быть: пустой конфиг {} бросит
CONFIG_INVALID сразу при резолве.
Внутри секций всё осталось как было — если секция auth передана, её
обязательные поля обязательны.
Объект или async-геттер
Конструктор принимает либо готовый объект, либо асинхронную функцию, которая его вернёт:
// Вариант 1 — конфиг известен сразу
const keycloak = new NodeKeycloak({
auth: {
url: 'https://kc.example.com',
username: 'service',
password: 'secret',
clientId: 'my-app',
clientSecret: 'my-app-secret'
}
});
// Вариант 2 — конфиг приезжает асинхронно (Vault, БД, remote config)
const keycloak = new NodeKeycloak(async () => {
const secrets = await vault.read('keycloak');
return {
auth: {
url: secrets.url,
username: secrets.login,
password: secrets.password,
clientId: secrets.clientId,
clientSecret: secrets.clientSecret
}
};
});Конструктор синхронный и ничего не делает: он только запоминает вход. Все методы инстанса асинхронные, поэтому конфиг резолвится внутри первого же вызова.
Геттер вызывается один раз — результат кешируется. Даже если
параллельно дёрнуть три метода, геттер отработает единожды. Чтобы
перечитать конфиг (например, после ротации секретов), вызовите
reload().
Bearer и Basic
Тип авторизации задаёт поле auth.authType. Типы устроены так, что
компилятор сам подскажет, какие поля нужны:
import NodeKeycloak, { ConfigTypes } from '@uzum-tech/node-keycloak';
// Bearer (значение по умолчанию) — clientId и clientSecret обязательны
const bearer = new NodeKeycloak({
auth: {
url, username, password,
clientId: 'my-app',
clientSecret: 'my-app-secret'
}
});
// Basic — clientId и clientSecret не нужны и запрещены
const basic = new NodeKeycloak({
auth: {
url, username, password,
authType: ConfigTypes.AuthType.BASIC
}
});Что скажет TypeScript, если ошибиться:
// Ошибка: Property 'clientId' is missing
new NodeKeycloak({
auth: { url, username, password }
});
// Ошибка: Type 'string' is not assignable to type 'undefined'
new NodeKeycloak({
auth: {
url, username, password,
authType: ConfigTypes.AuthType.BASIC,
clientId: 'my-app'
}
});Разница в поведении:
| | Bearer | Basic |
|---|---|---|
| Запросы к Keycloak | да, password grant | нет, ни одного |
| getAuthHeader() | Bearer <access_token> | Basic <base64(login:pass)> |
| signIn / refresh / signOut / getToken | работают | бросают UNSUPPORTED_AUTH_TYPE |
| verify() | работает | работает |
Basic — это статические учётные данные, у них нет жизненного цикла
токена. Поэтому единственный осмысленный способ получить заголовок —
getAuthHeader(), и он работает для обоих режимов одинаково. Пишите
код на getAuthHeader(), и смена режима в конфиге ничего не сломает.
Только проверка токенов, без auth
Сервису, который сам никуда не ходит под сервисной учёткой, а только
проверяет входящие токены, секция auth не нужна — достаточно jwks:
// Вариант 1 — ключи лежат рядом, сети не будет вообще
const keycloak = new NodeKeycloak({
jwks: {
cert: readFileSync('./certs/keycloak-jwks.json', 'utf8'),
issuer: 'https://kc.example.com/realms/auth',
audience: ['account']
}
});
// Вариант 2 — ключи забираются с эндпоинта реалма
const keycloak = new NodeKeycloak({
jwks: {
issuer: 'https://kc.example.com/realms/auth',
audience: ['account']
}
});
await keycloak.verify(incomingToken);Без auth пакету неоткуда взять адрес реалма, поэтому источник ключей
задаёт сама секция jwks:
jwks.cert— проверка идёт офлайн, ничего больше не нужно;jwks.issuer— из него собирается URL certs-эндпоинта (<issuer>/protocol/openid-connect/certs).
Если не задано ни то, ни другое, verify() бросит
JWKS_NOT_CONFIGURED. Токенные методы (getToken, getAuthHeader,
signIn и остальные) в таком конфиге бросают AUTH_NOT_CONFIGURED —
это ожидаемо, добавьте секцию auth, если они нужны.
Все поля конфига
Секция auth — нужна для всего, что связано с токенами:
| Поле | Тип | Обязательное | По умолчанию | Описание |
|---|---|---|---|---|
| url | string | да | — | Базовый URL Keycloak. Можно с префиксом пути: https://kc.example.com/auth |
| username | string | да | — | Логин сервисной учётки |
| password | string | да | — | Пароль сервисной учётки |
| clientId | string | да для Bearer | — | Запрещён для Basic |
| clientSecret | string | да для Bearer | — | Запрещён для Basic |
| authType | ConfigTypes.AuthType | нет | Bearer | Тип авторизации |
| realm | string | нет | 'auth' | Реалм |
| scope | string | нет | 'openid' | Scope для token-запросов |
| requestTimeoutInMs | number | нет | 30000 | Таймаут HTTP-запросов к Keycloak |
| cache.maxEntriesCount | number | нет | 100 | Размер LRU-кеша токенов |
| cache.ttlInHours | number | нет | 24 | Максимальный TTL записи |
| cache.expiryLeewayInSec | number | нет | 10 | За сколько секунд до exp считать токен протухшим |
Секция jwks — нужна для verify(); все поля опциональны:
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| cert | string | — | Контент сертификата: JWKS-JSON или PEM. Если задан, сеть не используется |
| cacheEnabled | boolean | true | Кешировать ключи, полученные с JWKS-эндпоинта |
| cacheInHours | number | 24 | Сколько часов держать ключи в кеше |
| timeoutInMs | number | 30000 | Таймаут запроса к JWKS-эндпоинту |
| issuer | string | <auth.url>/realms/<auth.realm> | Ожидаемый iss в токене. Без секции auth из него же собирается адрес certs-эндпоинта |
| audience | string[] | — | Ожидаемый aud. Если не задан, aud не проверяется |
Резолвнутый конфиг доступен через getConfig(): в нём auth — это
либо объект с подставленными умолчаниями, либо null, если секцию не
передавали.
API инстанса
Токены
getToken(): Promise<TokenTypes.SignInResponse>
Главный метод. Возвращает валидный токен, делая минимум работы:
- в кеше лежит живой токен → отдаёт его, без сетевых запросов;
- access протух, но refresh ещё жив → делает
refresh_tokengrant; - refresh тоже протух или рефреш не удался → делает
passwordgrant.
Параллельные вызовы схлопываются: десять одновременных getToken()
дадут ровно один запрос в Keycloak.
const token = await keycloak.getToken();
token.access_token; // 'eyJhbGciOi...'
token.refresh_token; // 'eyJhbGciOi...'
token.expires_in; // 300
token.refresh_expires_in; // 1800
token.token_type; // 'Bearer'getAuthHeader(): Promise<string>
То же самое, но сразу готовой строкой для заголовка Authorization.
Для Basic-режима отдаёт Basic <base64> без обращения к сети.
const authorization = await keycloak.getAuthHeader();getAccessToken(): Promise<string>
Только сам access-токен строкой.
getTokenCamelCase(): Promise<TokenTypes.Token>
Ответ Keycloak в camelCase — удобно отдавать наружу из своего API.
const {
access, refresh, accessExpiresInSec, refreshExpiresInSec, type, scope
} = await keycloak.getTokenCamelCase();Соответствие полей — один к одному с ответом Keycloak:
access_token → access, refresh_token → refresh,
expires_in → accessExpiresInSec,
refresh_expires_in → refreshExpiresInSec,
token_type → type, scope → scope.
signIn(payload?): Promise<TokenTypes.SignInResponse>
Явный вход по password grant. Результат кладётся в кеш.
await keycloak.signIn();
// Войти под другим пользователем, не трогая конфиг
await keycloak.signIn({
username: 'other-user',
password: 'other-password'
});refresh(payload?): Promise<TokenTypes.SignInResponse>
Явное обновление. Без аргументов берёт refresh-токен из кеша.
await keycloak.refresh();
await keycloak.refresh({ refreshToken: tokenFromClient });Если refresh-токена нет ни в аргументе, ни в кеше — бросит
TOKEN_MISSING.
signOut(payload?): Promise<void>
Разлогинивает сессию в Keycloak и чистит кеш.
await keycloak.signOut();
await keycloak.signOut({ refreshToken: tokenFromClient });Кеш чистится до сетевого вызова — даже если Keycloak ответит ошибкой, локально токена уже не будет.
Проверка токена
verify(token, options?): Promise<JwtTypes.Payload>
Проверяет подпись, срок жизни, issuer и (если задан) audience.
Возвращает payload или бросает KeycloakError.
const payload = await keycloak.verify(token);
payload.preferred_username; // 'admin'
payload.email; // '[email protected]'
payload.realm_access?.roles; // ['admin', 'APP_USER']Принимает как «голый» токен, так и строку вида Bearer eyJ... — префикс
отрезается сам, поэтому заголовок можно передавать как есть:
const payload = await keycloak.verify(request.headers.authorization);Опции второго аргумента перекрывают умолчания и совпадают с
jsonwebtoken:
await keycloak.verify(token, {
audience: 'my-service',
clockTolerance: 5,
algorithms: [JwtTypes.CertAlg.RS256]
});isValid(token, options?): Promise<boolean>
То же самое, но вместо исключения — true / false.
decode(token?): JwtTypes.Payload | null
Синхронно разбирает токен без проверки подписи. Подходит, чтобы
прочитать preferred_username или роли из уже проверенного токена.
Никогда не принимайте решения о доступе по одному
decode()— подпись при этом не проверяется.
isTokenExpired(token?, thresholdInSec?): boolean
Синхронная проверка exp. Пустой токен считается протухшим.
Служебные методы
| Метод | Что делает | Нужна секция auth |
|---|---|---|
| getConfig() | Резолвит конфиг и возвращает его с подставленными умолчаниями | нет |
| getIssuer() | <auth.url>/realms/<auth.realm> | да |
| getCertsUri() | URL JWKS-эндпоинта: из auth, а без него — из jwks.issuer | нет |
| getUserInfo() | Запрос к userinfo под текущим токеном | да |
| clearCache() | Выбрасывает токен из кеша | нет |
| reload() | Сбрасывает конфиг и кеш — геттер вызовется заново | нет |
| close() | Закрывает HTTP-соединения и чистит кеш | нет |
close() стоит вызывать при graceful shutdown приложения и в
afterAll тестов, иначе открытые сокеты не дадут процессу завершиться.
Как работает кеш и авторефреш
Токен лежит в LRU-кеше. Ключ собирается из auth.url, auth.realm,
auth.authType, auth.clientId и auth.username — поэтому два
инстанса с разными учётками не мешают друг другу, а два одинаковых
делят запись.
getToken()
│
├─ в кеше есть токен и до exp больше expiryLeewayInSec
│ └─> отдаём из кеша, сетевых запросов нет
│
├─ access протух, refresh ещё живой
│ └─> grant_type=refresh_token
│ ├─ успех -> кладём в кеш, отдаём
│ └─ ошибка -> чистим кеш и логинимся заново
│
└─ refresh протух или его нет
└─> grant_type=passwordДва момента, о которых стоит знать:
auth.cache.expiryLeewayInSec (по умолчанию 10). Токен считается протухшим за
10 секунд до реального exp. Это защищает от гонки, когда токен был
валиден в момент проверки, но истёк, пока запрос шёл по сети. Если
Keycloak выдаёт очень короткие токены (меньше leeway), они будут
обновляться сразу — уменьшите значение.
TTL записи в кеше считается по refresh_expires_in, а не по
expires_in. Иначе запись исчезала бы вместе с access-токеном и
уносила бы с собой refresh-токен, превращая каждый рефреш в повторный
логин.
Проверка подписи через JWKS
Есть два режима, и переключает их наличие jwks.cert.
Режим по умолчанию — ключи с эндпоинта реалма. Пакет ходит на
<auth.url>/realms/<auth.realm>/protocol/openid-connect/certs, находит
ключ по kid из заголовка токена и кеширует его на jwks.cacheInHours.
const keycloak = new NodeKeycloak({
auth: { url, username, password, clientId, clientSecret },
jwks: {
audience: ['realm-management', 'account'],
cacheInHours: 24
}
});Режим с готовым сертификатом — без сети. Передайте контент файла в
jwks.cert. Понимаются оба формата:
import { readFileSync } from 'node:fs';
const keycloak = new NodeKeycloak({
auth: { url, username, password, clientId, clientSecret },
jwks: {
cert: readFileSync('./certs/keycloak-jwks.json', 'utf8')
}
});// PEM тоже подойдёт
jwks: {
cert: readFileSync('./certs/keycloak.pem', 'utf8')
}JWKS-JSON — это { "keys": [ ... ] }, который отдаёт эндпоинт
/certs. Ключ выбирается по kid из токена; JWK конвертируется в
публичный ключ штатным node:crypto.
Поле опционально, и его отсутствие не приводит к ошибке при
создании инстанса — пакет просто пойдёт за ключами в сеть. Ошибка
возникнет только в момент verify(), если ключи не удалось получить
ни одним способом.
Секция jwks тоже опциональна целиком: с одной только секцией auth
проверка работает на умолчаниях — ключи берутся с эндпоинта реалма,
iss ожидается равным <auth.url>/realms/<auth.realm>, aud не
проверяется.
Откуда берётся адрес certs-эндпоинта:
| Конфиг | Источник ключей |
|---|---|
| auth + jwks.cert | jwks.cert, сети нет |
| auth без jwks.cert | <auth.url>/realms/<auth.realm>/protocol/openid-connect/certs |
| только jwks.cert | jwks.cert, сети нет |
| только jwks.issuer | <jwks.issuer>/protocol/openid-connect/certs |
| ни auth, ни cert/issuer | verify() бросит JWKS_NOT_CONFIGURED |
Ошибки
Все ошибки пакета — экземпляры KeycloakError с полем code:
import { ErrorTypes, KeycloakError } from '@uzum-tech/node-keycloak';
try {
await keycloak.getToken();
} catch (error) {
if (KeycloakError.is(error, ErrorTypes.Code.REQUEST_FAILED)) {
error.statusCode; // 401
error.body; // { error: 'invalid_grant', error_description: '...' }
}
}| Код | Когда возникает |
|---|---|
| CONFIG_INVALID | Конфиг пустой, в секции auth не хватает обязательных полей или auth.url не абсолютный |
| AUTH_NOT_CONFIGURED | Вызов токенного метода в конфиге без секции auth |
| UNSUPPORTED_AUTH_TYPE | Вызов токенного метода в режиме Basic |
| JWKS_NOT_CONFIGURED | jwks.cert не парсится, в нём нет нужного kid, либо ключи брать неоткуда: нет ни auth, ни jwks.cert, ни jwks.issuer |
| REQUEST_FAILED | Keycloak ответил статусом >= 400 |
| TOKEN_MISSING | Нет refresh-токена для refresh() или пустой токен в verify() |
| VERIFY_FAILED | Подпись, exp, iss или aud не сошлись |
KeycloakError.is(error) без второго аргумента проверяет только тип.
Оба варианта сужают тип для TypeScript, так что после проверки
error.statusCode доступен без приведения.
Текст ошибки REQUEST_FAILED уже содержит error_description от
Keycloak, а оригинальное тело ответа лежит в error.body.
HTTP-клиент с автоматической авторизацией
Своего HTTP-клиента пакет наружу не отдаёт: если за Keycloak стоит API,
куда нужно ходить под сервисным токеном, соберите клиент
@uzum-tech/node-http-client
и передайте ему инстанс как стратегию авторизации:
import NodeKeycloak from '@uzum-tech/node-keycloak';
import {
HttpClient, KeycloakAuthStrategy
} from '@uzum-tech/node-http-client';
const keycloak = new NodeKeycloak({
auth: {
url: process.env.API_KC_AUTH_URL!,
username: process.env.API_KC_LOGIN!,
password: process.env.API_KC_PASSWORD!,
clientId: process.env.API_KC_CLIENT!,
clientSecret: process.env.API_KC_SECRET!
}
});
export const bankClient = new HttpClient({
baseUrl: process.env.API_KC_URL!,
auth: new KeycloakAuthStrategy(keycloak)
});Дальше клиент сам проставляет Authorization — перед каждым запросом
стратегия дёргает getAuthHeader(), то есть работают и кеш, и
авторефреш:
const data = await bankClient.get<Information>(
'/bank-clients/api/common/get-information'
);KeycloakAuthStrategy не завязана на наш пакет: ей нужен любой объект с
методом getAuthHeader(). Клиент создаётся один раз на интеграцию и
держит пул соединений — объявляйте его константой модуля, а при
graceful shutdown закрывайте вместе с инстансом:
await Promise.all([bankClient.close(), keycloak.close()]);Остальное — методы, content-type, TLS, ошибки — описано в README самого http-клиента.
Рецепты
NestJS: провайдер
import { Global, Module } from '@nestjs/common';
import NodeKeycloak from '@uzum-tech/node-keycloak';
export const KEYCLOAK = Symbol('KEYCLOAK');
@Global()
@Module({
providers: [
{
provide: KEYCLOAK,
useFactory: () => new NodeKeycloak({
auth: {
url: process.env.API_KC_AUTH_URL!,
username: process.env.API_KC_LOGIN!,
password: process.env.API_KC_PASSWORD!,
clientId: process.env.API_KC_CLIENT!,
clientSecret: process.env.API_KC_SECRET!
},
jwks: {
audience: ['realm-management', 'account']
}
})
}
],
exports: [KEYCLOAK]
})
export default class KeycloakModule {}NestJS: guard поверх verify
Пакет ничего не знает про ExecutionContext — достаньте токен сами и
передайте строку:
@Injectable()
export default class AuthVerifyGuard implements CanActivate {
constructor(
@Inject(KEYCLOAK) private readonly keycloak: NodeKeycloak
) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const request = ctx.switchToHttp().getRequest<FastifyRequest>();
const token = request.headers.authorization || '';
try {
await this.keycloak.verify(token);
return true;
} catch (error) {
throw new UnauthorizedException(error);
}
}
}Сервис, который только проверяет токены
Секцию auth не заводим вовсе — тогда учётные данные сервису не нужны:
export const keycloak = new NodeKeycloak({
jwks: {
issuer: `${process.env.API_KC_AUTH_URL}/realms/auth`,
audience: ['account']
}
});
// дальше в guard / middleware
await keycloak.verify(request.headers.authorization);Проверка ролей
Ролей в пакете нет намеренно — они у каждого проекта свои. Берите их из проверенного payload:
const payload = await keycloak.verify(token);
const roles = payload.realm_access?.roles ?? [];
if (!roles.includes('APP_ADMIN')) {
throw new ForbiddenException();
}Graceful shutdown
process.on('SIGTERM', async () => {
await keycloak.close();
await app.close();
});Ротация секретов
const keycloak = new NodeKeycloak(async () => fetchSecrets());
// после ротации: конфиг перечитается, кеш и соединения сбросятся
await keycloak.reload();FAQ
Инстанс создавать один на приложение или на запрос? Один на приложение. Кеш токена живёт внутри инстанса — если создавать новый на каждый запрос, кеш будет пустым и каждый раз пойдёт логин.
Нужно ли самому следить за истечением токена?
Нет. Просто вызывайте getToken() или getAuthHeader() перед каждым
исходящим запросом — они дешёвые, пока токен в кеше живой.
Почему signIn() не работает в режиме Basic?
У Basic-авторизации нет токенов и, соответственно, жизненного цикла.
Используйте getAuthHeader() — он работает в обоих режимах.
verify() бросает VERIFY_FAILED на валидном токене
Чаще всего не сходится audience. По умолчанию aud не проверяется,
но если вы задали jwks.audience, токен обязан содержать одно из этих
значений. Посмотрите фактический payload через decode(token).
Как проверять токены другого реалма?
Заведите отдельный инстанс с нужным auth.realm — у каждого свой кеш
ключей и свой ожидаемый issuer.
Нужен только verify(), учётки сервиса нет — что передавать?
Только секцию jwks: cert для офлайн-проверки или issuer, чтобы
пакет сам собрал адрес certs-эндпоинта. Секция auth в этом случае не
нужна, а токенные методы будут бросать AUTH_NOT_CONFIGURED.
Процесс не завершается после тестов
Не закрыты HTTP-соединения. Вызовите await keycloak.close().
Можно ли использовать слои по отдельности?
Да, из корня пакета экспортируются TokenService, VerifyService,
TokenCache, ApiKeycloakAuth, JwtUtils, CertUtils и остальные —
если фасад чем-то не подходит, соберите свой из тех же деталей.
