npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@uzum-tech/node-keycloak

Клиент Keycloak для Node.js без привязки к фреймворку. Вход, выход, хранение токена в кеше, автоматический рефреш и проверка подписи токена через JWKS — в одном инстансе.


Содержание


Зачем

В каждом сервисе повторялся один и тот же код: сходить в Keycloak за токеном, положить его в кеш, вовремя обновить, а входящие токены проверить по сертификату. Пакет собирает это в один объект.

Что внутри:

  • вход — password grant с логином/паролем и 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>

Главный метод. Возвращает валидный токен, делая минимум работы:

  1. в кеше лежит живой токен → отдаёт его, без сетевых запросов;
  2. access протух, но refresh ещё жив → делает refresh_token grant;
  3. refresh тоже протух или рефреш не удался → делает password grant.

Параллельные вызовы схлопываются: десять одновременных 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 и остальные — если фасад чем-то не подходит, соберите свой из тех же деталей.