diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..3a9eee9
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,4 @@
+tests/
+dist/
+node_modules/
+coverage/
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..a209b71
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,28 @@
+name: Tests
+
+on:
+ pull_request:
+ branches: [main, master]
+ workflow_dispatch:
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "18"
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Build library
+ run: npm run build
+
+ - name: Run unit tests
+ run: npm run test:coverage
diff --git a/README.md b/README.md
index 4057abb..d9e3c09 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,22 @@
# keycloak-backend
+
[](https://www.npmjs.com/package/keycloak-backend)
[](https://www.npmjs.com/package/keycloak-backend)
[](https://www.npmjs.com/package/keycloak-backend)
[](https://www.npmjs.com/package/keycloak-backend)
[](https://github.com/BackendStack21/keycloak-backend.git)
-
+
Keycloak Node.js minimalist connector for backend services integration. It aims to serve as base for high performance authorization middlewares.
> In order to use this module, the used Keycloak client `Direct Access Grants Enabled` setting should be `ON`
## Keycloak Introduction
+
The awesome open-source Identity and Access Management solution develop by RedHat.
Keycloak support those very nice features you are looking for:
+
- Single-Sign On
- LDAP and Active Directory
- Standard Protocols
@@ -29,72 +32,114 @@ Keycloak support those very nice features you are looking for:
More about Keycloak: http://www.keycloak.org/
+### Compatibility
+
+This library is tested against **Keycloak 26.0**. It supports modern Keycloak versions (18+) by default.
+For versions older than 18, set `is_legacy_endpoint: true`.
+
## Using the keycloak-backend module
+
### Configuration
+
```js
const Keycloak = require('keycloak-backend').Keycloak
const keycloak = new Keycloak({
"realm": "realm-name",
"keycloak_base_url": "https://keycloak.example.org",
"client_id": "super-secure-client",
- "username": "user@example.org",
- "password": "passw0rd",
- "is_legacy_endpoint": false
+ "client_secret": "super-secure-secret", // Optional: for client_credentials grant
+ "username": "user@example.org", // Optional: for password grant
+ "password": "passw0rd", // Optional: for password grant
+ "is_legacy_endpoint": false, // Optional: true for Keycloak < 18
+ "timeout": 10000, // Optional: HTTP request timeout in ms (default: 10000)
+ "httpsAgent": new https.Agent({ ... }), // Optional: Custom HTTPS agent
+ "onError": (err, ctx) => console.error(ctx, err) // Optional: Error handler hook
})
```
+
> The `is_legacy_endpoint` configuration property should be TRUE for older Keycloak versions (under 18)
For TypeScript:
+
```ts
-import { Keycloak } from "keycloak-backend"
+import { Keycloak } from "keycloak-backend";
const keycloak = new Keycloak({
- "realm": "realm-name",
- "keycloak_base_url": "https://keycloak.example.org",
- "client_id": "super-secure-client",
- "username": "user@example.org",
- "password": "passw0rd",
- "is_legacy_endpoint": false
-})
+ realm: "realm-name",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "super-secure-client",
+ // ... other options
+});
```
### Generating access tokens
+
```js
-const accessToken = await keycloak.accessToken.get()
+const accessToken = await keycloak.accessToken.get();
```
+
Or:
+
```js
-request.get('http://service.example.org/api/endpoint', {
- 'auth': {
- 'bearer': await keycloak.accessToken.get()
- }
-})
+request.get("http://service.example.org/api/endpoint", {
+ auth: {
+ bearer: await keycloak.accessToken.get(),
+ },
+});
```
### Validating access tokens
+
#### Online validation
+
This method requires online connection to the Keycloak service to validate the access token. It is highly secure since it also check for possible token invalidation. The disadvantage is that a request to the Keycloak service happens on every validation:
+
```js
-const token = await keycloak.jwt.verify(accessToken)
+const token = await keycloak.jwt.verify(accessToken);
//console.log(token.isExpired())
//console.log(token.hasRealmRole('user'))
//console.log(token.hasApplicationRole('app-client-name', 'some-role'))
```
#### Offline validation
+
This method perform offline JWT verification against the access token using the Keycloak Realm public key. Performance is higher compared to the online method, as a disadvantage no access token invalidation on Keycloak server is checked:
+
```js
-const cert = fs.readFileSync('public_cert.pem')
-const token = await keycloak.jwt.verifyOffline(accessToken, cert)
+// Ensure your public key is in PEM format
+const cert = `-----BEGIN PUBLIC KEY-----
+...your public key...
+-----END PUBLIC KEY-----`;
+const token = await keycloak.jwt.verifyOffline(accessToken, cert);
//console.log(token.isExpired())
//console.log(token.hasRealmRole('user'))
//console.log(token.hasApplicationRole('app-client-name', 'some-role'))
```
+## Testing
+
+The project includes a comprehensive integration test suite that runs against a real Keycloak instance using Docker Compose.
+
+To run the integration tests:
+
+```bash
+npm run test:integration
+```
+
+This will:
+
+1. Spin up a Keycloak 26 container and a Postgres database.
+2. Import a test realm with pre-configured clients and users.
+3. Run the test suite to verify token generation, validation (online/offline), and role checks.
+4. Tear down the environment.
+
## Breaking changes
+
### v4
+
- Codebase migrated from JavaScript to TypeScript. Many thanks to @neferin12
### v3
+
- The `UserManager` class was dropped
- The `auth-server-url` config property was changed to `keycloak_base_url`
- Most recent Keycloak API is supported by default, old versions are still supported through the `is_legacy_endpoint` config property
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..0776fc4
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,47 @@
+version: '3.8'
+
+services:
+ postgres:
+ image: postgres:15-alpine
+ container_name: keycloak-postgres
+ environment:
+ POSTGRES_DB: keycloak
+ POSTGRES_USER: keycloak
+ POSTGRES_PASSWORD: keycloak
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U keycloak"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ keycloak:
+ image: quay.io/keycloak/keycloak:26.0
+ container_name: keycloak-test
+ environment:
+ KC_DB: postgres
+ KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
+ KC_DB_USERNAME: keycloak
+ KC_DB_PASSWORD: keycloak
+ KEYCLOAK_ADMIN: admin
+ KEYCLOAK_ADMIN_PASSWORD: admin
+ KC_HEALTH_ENABLED: true
+ KC_METRICS_ENABLED: true
+ KC_HTTP_ENABLED: true
+ command:
+ - start-dev
+ - --import-realm
+ ports:
+ - "8080:8080"
+ volumes:
+ - ./tests/integration/realm-export.json:/opt/keycloak/data/import/realm-export.json
+ depends_on:
+ postgres:
+ condition: service_healthy
+
+
+volumes:
+ postgres_data:
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 0000000..82006cd
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,18 @@
+module.exports = {
+ preset: "ts-jest",
+ testEnvironment: "node",
+ roots: ["/libs", "/tests"],
+ testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
+ testPathIgnorePatterns: ["/node_modules/", "/tests/integration/"],
+ collectCoverageFrom: ["libs/**/*.ts", "!libs/**/*.d.ts", "!libs/index.ts"],
+ coverageThreshold: {
+ global: {
+ branches: 90,
+ functions: 100,
+ lines: 100,
+ statements: 100,
+ },
+ },
+ coverageReporters: ["text", "lcov", "html"],
+ moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
+};
diff --git a/jest.integration.config.js b/jest.integration.config.js
new file mode 100644
index 0000000..605bb7b
--- /dev/null
+++ b/jest.integration.config.js
@@ -0,0 +1,8 @@
+module.exports = {
+ preset: "ts-jest",
+ testEnvironment: "node",
+ roots: ["/tests/integration"],
+ testMatch: ["**/integration.test.ts"],
+ testTimeout: 30000,
+ moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
+};
diff --git a/libs/AccessToken.ts b/libs/AccessToken.ts
index dd06edb..5b362e3 100644
--- a/libs/AccessToken.ts
+++ b/libs/AccessToken.ts
@@ -1,89 +1,194 @@
-import { stringify } from 'querystring'
-import { IInternalConfig } from './index'
-import { AxiosInstance } from 'axios'
+import { stringify } from "querystring";
+import { IInternalConfig } from "./index";
+import { AxiosInstance } from "axios";
+/** Parameters shared by token request methods. */
interface ICommonRequestOptions {
- grant_type: string
- client_id: string
- client_secret?: string
+ grant_type: string;
+ client_id: string;
+ client_secret?: string;
}
+/** Options object for token request used by `get()` (and tests). */
interface IGetOptions extends ICommonRequestOptions {
- username?: string
- password?: string
- scope?: string
+ username?: string;
+ password?: string;
+ scope?: string;
}
+/** Options for refresh token request. */
interface IRefreshOptions extends ICommonRequestOptions {
- refresh_token: string
+ refresh_token: string;
}
+/**
+ * AccessToken provides a simple stateful wrapper for a Keycloak
+ * client credentials or resource owner password credentials grant token,
+ * with an auto-refresh capability and safety limits to avoid infinite
+ * recursion during authentication retries.
+ */
export class AccessToken {
- private data: any
+ // Cached token data from Keycloak. Null means 'no token available'
+ private data: any;
+ // Maximum number of retries to prevent infinite recursion during refresh
+ private readonly maxRetries: number = 3;
- constructor (private readonly config: IInternalConfig, private readonly client: AxiosInstance) {
+ constructor(private readonly config: IInternalConfig, private readonly client: AxiosInstance) {}
+
+ // Helper to push errors to the optional `onError` hook provided by
+ // callers. This centralizes error logging and allows production users
+ // to capture security-relevant events if desired.
+ private logError(error: Error, context: string): void {
+ if (this.config.onError != null) {
+ this.config.onError(error, context);
+ }
}
- async info (accessToken: string): Promise {
- const endpoint = `${this.config.prefix}/realms/${this.config.realm}/protocol/openid-connect/userinfo`
+ /**
+ * Retrieve Keycloak userinfo for the provided access token.
+ *
+ * This method performs a GET on the Keycloak `/userinfo` endpoint and
+ * returns the parsed JSON body. If the call fails (network or HTTP
+ * error), the underlying Axios error will be propagated.
+ *
+ * @param accessToken - The raw access token string
+ * @returns Resolves with the Keycloak userinfo object as returned by `/userinfo`
+ * @throws {AxiosError} When the request fails; synchronously re-thrown
+ * @example
+ * const info = await accessToken.info('ey...')
+ */
+ async info(accessToken: string): Promise {
+ const endpoint = `${this.config.prefix}/realms/${this.config.realm}/protocol/openid-connect/userinfo`;
const response = await this.client.get(endpoint, {
headers: {
- Authorization: 'Bearer ' + accessToken
- }
- })
+ Authorization: "Bearer " + accessToken,
+ },
+ });
- return response.data
+ return response.data;
}
- async refresh (refreshToken: string): Promise {
+ /**
+ * Exchange a refresh token for a new access token / refresh token pair.
+ *
+ * This method uses Keycloak's `refresh_token` grant type. On success
+ * the full Axios response is returned and the caller can read
+ * `response.data` for the `access_token` value.
+ *
+ * @param refreshToken - Refresh token string from a previously issued token pair
+ * @returns Resolves with the Axios response containing `data` with the refreshed token pair
+ * @throws {AxiosError} When the request fails (e.g., refresh token expired)
+ * @example
+ * const resp = await accessToken.refresh('refresh-token')
+ * // resp.data.access_token -> 'new-token'
+ */
+ async refresh(refreshToken: string): Promise {
const options: IRefreshOptions = {
- grant_type: 'refresh_token',
+ grant_type: "refresh_token",
client_id: this.config.client_id,
- refresh_token: refreshToken
- }
+ refresh_token: refreshToken,
+ };
if (this.config.client_secret != null) {
- options.client_secret = this.config.client_secret
+ options.client_secret = this.config.client_secret;
}
- const endpoint = `${this.config.prefix}/realms/${this.config.realm}/protocol/openid-connect/token`
- return await this.client.post(endpoint, stringify({ ...options }))
+ const endpoint = `${this.config.prefix}/realms/${this.config.realm}/protocol/openid-connect/token`;
+ return await this.client.post(endpoint, stringify({ ...options }));
}
- async get (scope?: string): Promise {
+ /**
+ * Returns a valid access token; if a token is cached it'll be validated
+ * via `userinfo`, otherwise a new token request is performed.
+ *
+ * Logic summary:
+ * - If `this.data` is null: perform a token request. The default
+ * grant is `client_credentials`; when `username` and `password` are
+ * configured the `password` grant is used instead.
+ * - If the token is present: validate it by calling Keycloak's
+ * `/userinfo`. If validation fails and a `refresh_token` exists,
+ * attempt to refresh. If refresh fails or no refresh token exists,
+ * re-authenticate (with capped retries).
+ *
+ * @param scope - Optional OIDC scope to request (defaults to `openid`)
+ * @param depth - Internal retry depth (used to prevent recursion)
+ * @returns A Promise resolving to the access token string
+ * @throws {Error} `Max authentication retry depth exceeded` when too many retries
+ * @throws {AxiosError} When token requests, `userinfo` calls or refresh fail
+ * @example
+ * const token = await accessToken.get() // default `openid` scope
+ * const userToken = await accessToken.get('openid profile')
+ */
+ async get(scope?: string, depth: number = 0): Promise {
+ // Prevent infinite recursion when repeated attempts fail; report to
+ // monitoring hook and raise an error to the caller.
+ if (depth >= this.maxRetries) {
+ const error = new Error("Max authentication retry depth exceeded");
+ this.logError(error, "AccessToken.get");
+ throw error;
+ }
+
+ // No cached token; request a new one. By default use the
+ // `client_credentials` grant - it is preferred for service-to-service
+ // flows. If the runtime configuration contains `username`/`password`,
+ // switch to `password` grant to support resource-owner flows.
if (this.data == null) {
const options: IGetOptions = {
- grant_type: 'password',
- username: this.config.username,
- password: this.config.password,
- client_id: this.config.client_id
+ grant_type: "client_credentials",
+ client_id: this.config.client_id,
+ };
+
+ if (this.config.username != null && this.config.password != null) {
+ options.grant_type = "password";
+ options.username = this.config.username;
+ options.password = this.config.password;
}
+
+ // Attach a client secret when available; certain Keycloak clients
+ // require it for the client credentials flow.
if (this.config.client_secret != null) {
- options.client_secret = this.config.client_secret
- }
- if (scope != null) {
- options.scope = scope
+ options.client_secret = this.config.client_secret;
}
- const endpoint = `${this.config.prefix}/realms/${this.config.realm}/protocol/openid-connect/token`
- const response = await this.client.post(endpoint, stringify({ ...options }))
- this.data = response.data
+ // Default scope to `openid` for Keycloak's userinfo endpoint and
+ // token policies; allow overriding by callers.
+ options.scope = scope ?? "openid";
+
+ const endpoint = `${this.config.prefix}/realms/${this.config.realm}/protocol/openid-connect/token`;
+ try {
+ const response = await this.client.post(endpoint, stringify({ ...options }));
+ this.data = response.data;
- return this.data.access_token
+ return this.data.access_token;
+ } catch (err) {
+ this.logError(err instanceof Error ? err : new Error(String(err)), "AccessToken.get:request");
+ throw err;
+ }
} else {
try {
- await this.info(this.data.access_token)
+ // Validate the token via `userinfo` endpoint. If validation
+ // succeeds the token is still valid; otherwise a refresh may be
+ // attempted or a full re-authentication will be performed.
+ await this.info(this.data.access_token);
- return this.data.access_token
+ return this.data.access_token;
} catch (err) {
+ this.logError(err instanceof Error ? err : new Error(String(err)), "AccessToken.get:info");
try {
- const response = await this.refresh(this.data.refresh_token)
- this.data = response.data
+ // Try to refresh using a refresh token if we have one.
+ if (this.data.refresh_token == null) {
+ throw new Error("No refresh token available");
+ }
+ const response = await this.refresh(this.data.refresh_token);
+ this.data = response.data;
- return this.data.access_token
+ return this.data.access_token;
} catch (err) {
- delete this.data
+ // If refresh fails, clear cached data and attempt a full
+ // re-authentication (with a capped retry depth).
+ this.logError(err instanceof Error ? err : new Error(String(err)), "AccessToken.get:refresh");
+ delete this.data;
- return await this.get(scope)
+ return await this.get(scope, depth + 1);
}
}
}
diff --git a/libs/Jwt.ts b/libs/Jwt.ts
index c16eb56..d1d6d61 100644
--- a/libs/Jwt.ts
+++ b/libs/Jwt.ts
@@ -1,31 +1,74 @@
-import { Token } from './Token'
-import { verify, VerifyOptions } from 'jsonwebtoken'
-import { IInternalConfig } from './index'
-import { AxiosInstance } from 'axios'
+import { Token } from "./Token";
+import { verify, VerifyOptions } from "jsonwebtoken";
+import { IInternalConfig } from "./index";
+import { AxiosInstance } from "axios";
+/**
+ * JWT helper for verifying and decoding tokens. Supports both server-side
+ * verification (online via Keycloak HTTP call) and offline signature
+ * verification using a public certificate.
+ */
export class Jwt {
- constructor (private readonly config: IInternalConfig, private readonly request: AxiosInstance) {}
+ constructor(private readonly config: IInternalConfig, private readonly request: AxiosInstance) {}
- async verifyOffline (accessToken: string, cert: any, options?: VerifyOptions): Promise {
+ /**
+ * Verify token offline using a public certificate.
+ * Defaults to `RS256` algorithm allowed list for safety.
+ *
+ * @param accessToken - JWT string to be verified
+ * @param cert - Public certificate or key used for verification
+ * @param options - Optional jsonwebtoken VerifyOptions
+ * @returns A Promise resolving to a `Token` instance if verification succeeds
+ * @throws {Error} When verification fails (signature mismatch or invalid token)
+ * @example
+ * const token = await jwt.verifyOffline('ey...', pubKey)
+ */
+ async verifyOffline(accessToken: string, cert: any, options?: VerifyOptions): Promise {
+ const verifyOptions: VerifyOptions = {
+ algorithms: ["RS256"],
+ ...options,
+ };
return await new Promise((resolve, reject) => {
- verify(accessToken, cert, options, (err) => {
- if (err != null) reject(err)
- resolve(new Token(accessToken))
- })
- })
+ verify(accessToken, cert, verifyOptions, (err) => {
+ if (err != null) reject(err);
+ resolve(new Token(accessToken));
+ });
+ });
}
- decode (accessToken: string): Token {
- return new Token(accessToken)
+ /**
+ * Decode a token into a `Token` wrapper without performing cryptographic
+ * verification. Useful in contexts where the token will be inspected
+ * but not trusted until verified by other means.
+ *
+ * @param accessToken - The JWT string to decode
+ * @returns A `Token` instance containing the parsed payload
+ * @example
+ * const token = jwt.decode('ey...')
+ */
+ decode(accessToken: string): Token {
+ return new Token(accessToken);
}
- async verify (accessToken: string): Promise {
+ /**
+ * Online verification that performs a Keycloak server `userinfo` call
+ * to make sure the token is still valid on the server-side. If the
+ * call completes successfully the token is accepted and returned as a
+ * `Token` wrapper for callers to inspect claims.
+ *
+ * @param accessToken - The JWT string to verify via Keycloak server
+ * @returns A Promise resolving to a `Token` instance when userinfo succeeds
+ * @throws {AxiosError} When the `userinfo` endpoint returns a non-2xx response
+ * @example
+ * const token = await jwt.verify('ey...')
+ */
+ async verify(accessToken: string): Promise {
await this.request.get(`${this.config.prefix}/realms/${this.config.realm}/protocol/openid-connect/userinfo`, {
headers: {
- Authorization: 'Bearer ' + accessToken
- }
- })
+ Authorization: "Bearer " + accessToken,
+ },
+ });
- return new Token(accessToken)
+ return new Token(accessToken);
}
}
diff --git a/libs/Keycloak.ts b/libs/Keycloak.ts
index 83834cd..b6cd96e 100644
--- a/libs/Keycloak.ts
+++ b/libs/Keycloak.ts
@@ -1,38 +1,80 @@
-import Axios from 'axios'
-import { AccessToken } from './AccessToken'
-import { Jwt } from './Jwt'
+import Axios from "axios";
+import { AccessToken } from "./AccessToken";
+import { Jwt } from "./Jwt";
+/**
+ * External configuration options accepted by the Keycloak client.
+ * Users may pass only the fields from this interface; internals will
+ * extend to `IInternalConfig` and shape additional fields at runtime.
+ */
export interface IExternalConfig {
- realm: string
- keycloak_base_url: string
- client_id: string
- username?: string
- password?: string
- client_secret?: string
- is_legacy_endpoint?: boolean
+ realm: string;
+ keycloak_base_url: string;
+ client_id: string;
+ username?: string;
+ password?: string;
+ client_secret?: string;
+ is_legacy_endpoint?: boolean;
+ timeout?: number;
+ httpsAgent?: any;
+ onError?: (error: Error, context: string) => void;
}
export interface IInternalConfig extends IExternalConfig {
- prefix: string
+ prefix: string;
}
+/**
+ * Main Keycloak entrypoint. Instantiates the HTTP client and exposes
+ * helper instances for token lifecycle management (`AccessToken`) and
+ * JWT verification (`Jwt`).
+ */
export class Keycloak {
- public readonly jwt: Jwt
- public readonly accessToken: AccessToken
+ public readonly jwt: Jwt;
+ public readonly accessToken: AccessToken;
- constructor (cfg: IExternalConfig) {
+ /**
+ * Construct a new Keycloak client instance.
+ *
+ * The instance provides `accessToken` and `jwt` helpers for programmatic
+ * token management and verification. This class does not perform network
+ * calls on construction.
+ *
+ * @param cfg - External configuration for Keycloak endpoints and credentials
+ * @example
+ * const keycloak = new Keycloak({
+ * realm: 'my-realm',
+ * keycloak_base_url: 'https://keycloak.example.org',
+ * client_id: 'my-client',
+ * client_secret: 'super-secret'
+ * })
+ * const token = await keycloak.accessToken.get()
+ */
+ constructor(cfg: IExternalConfig) {
+ // Build internal config from provided external config and compute
+ // a `prefix` used to support legacy Keycloak endpoints.
const icfg: IInternalConfig = {
...cfg,
- prefix: ''
- }
+ prefix: "",
+ };
+ // Create an Axios HTTP client with sensible defaults. The `timeout`
+ // is set to a defensive default of 10s, but can be overridden by the
+ // user via `cfg.timeout`. An optional custom `httpsAgent` is also
+ // supported for environments requiring custom TLS behavior.
const client = Axios.create({
- baseURL: icfg.keycloak_base_url
- })
+ baseURL: icfg.keycloak_base_url,
+ timeout: icfg.timeout ?? 10000,
+ httpsAgent: icfg.httpsAgent,
+ });
+ // When integration with Keycloak < 18 is required, a leading `/auth`
+ // prefix must be used for realm endpoint paths. This conditionally
+ // computes the correct `prefix` for all downstream client calls.
if (icfg.is_legacy_endpoint === true) {
- icfg.prefix = '/auth'
+ icfg.prefix = "/auth";
}
- this.accessToken = new AccessToken(icfg, client)
- this.jwt = new Jwt(icfg, client)
+ // Instantiate helper services with the configured HTTP client.
+ this.accessToken = new AccessToken(icfg, client);
+ this.jwt = new Jwt(icfg, client);
}
}
diff --git a/libs/Token.ts b/libs/Token.ts
index 502ba02..e16136a 100644
--- a/libs/Token.ts
+++ b/libs/Token.ts
@@ -1,53 +1,76 @@
-import { decode } from 'jsonwebtoken'
+import { decode } from "jsonwebtoken";
+/**
+ * Parsed JWT content. Most fields are the standard OIDC claims. Extra
+ * fields are optional and intentionally flattened into the interface to
+ * make them easily accessible when present.
+ */
export interface ITokenContent {
- [key: string]: any
+ [key: string]: any;
/**
- * Authorization server’s identifier
- */
- iss: string
+ * Authorization server’s identifier
+ */
+ iss: string;
/**
- * User’s identifier
- */
- sub: string
+ * User’s identifier
+ */
+ sub: string;
/**
- * Client’s identifier
- */
- aud: string | string[]
+ * Client’s identifier
+ */
+ aud: string | string[];
/**
- * Expiration time of the ID token
- */
- exp: number
+ * Expiration time of the ID token
+ */
+ exp: number;
/**
- * Time at which JWT was issued
- */
- iat: number
-
- family_name?: string
- given_name?: string
- name?: string
- email?: string
- preferred_username?: string
- email_verified?: boolean
+ * Time at which JWT was issued
+ */
+ iat: number;
+ family_name?: string;
+ given_name?: string;
+ name?: string;
+ email?: string;
+ preferred_username?: string;
+ email_verified?: boolean;
}
+/**
+ * Wrapper around a raw token string that provides convenience methods and
+ * a strongly-typed `content` object with the standard token claims. The
+ * constructor decodes the JWT without verifying signatures — callers
+ * should use `Jwt.verify` or `Jwt.verifyOffline` for verification.
+ */
export class Token {
- public readonly token: string
- public readonly content: ITokenContent
+ public readonly token: string;
+ public readonly content: ITokenContent;
- constructor (token: string) {
- this.token = token
- const payload = decode(this.token, { json: true })
+ /**
+ * Construct a Token wrapper around a raw JWT string. The constructor
+ * decodes the payload (without verifying cryptographic signature).
+ * Callers should verify tokens before trusting them.
+ *
+ * @param token - Raw JWT access token string
+ * @throws {Error} when mandatory OIDC claims (`iss`, `sub`, `aud`, `exp`, `iat`) are missing
+ * @example
+ * const token = new Token('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...')
+ */
+ constructor(token: string) {
+ this.token = token;
+ const payload = decode(this.token, { json: true });
+ // Basic structural validation: ensure the standard claims exist.
+ // Note: Keycloak may use 'azp' (authorized party) instead of 'aud' for access tokens
+ const aud = payload?.aud ?? payload?.azp;
if (
payload?.iss !== undefined &&
payload?.sub !== undefined &&
- payload?.aud !== undefined &&
+ aud !== undefined &&
payload?.exp !== undefined &&
payload?.iat !== undefined
) {
@@ -55,29 +78,62 @@ export class Token {
...payload,
iss: payload.iss,
sub: payload.sub,
- aud: payload.aud,
+ aud: aud,
exp: payload.exp,
- iat: payload.iat
- }
+ iat: payload.iat,
+ };
} else {
- throw new Error('Invalid token')
+ // If core OIDC claims are missing we don't attempt to work with the
+ // token and instead fail fast with an explanatory error.
+ throw new Error("Invalid token");
}
}
- isExpired (): boolean {
- return (this.content.exp * 1000) <= Date.now()
+ /**
+ * Check whether the token has expired using the `exp` claim.
+ *
+ * @returns true when token is expired
+ * @example
+ * token.isExpired() // => true or false
+ */
+ isExpired(): boolean {
+ return this.content.exp * 1000 <= Date.now();
}
- hasApplicationRole (appName: string, roleName: string): boolean {
- const appRoles = this.content.resource_access[appName]
- if (appRoles == null) {
- return false
+ /**
+ * Check whether the token contains a role for a specific application
+ * (client). Returns false if the claim is missing.
+ *
+ * @param appName - Client/application name
+ * @param roleName - Role name to check
+ * @returns true when the role exists for the application
+ * @example
+ * token.hasApplicationRole('my-app', 'viewer') // => true | false
+ */
+ hasApplicationRole(appName: string, roleName: string): boolean {
+ if (this.content.resource_access == null) {
+ return false;
+ }
+ const appRoles = this.content.resource_access[appName];
+ if (appRoles == null || appRoles.roles == null) {
+ return false;
}
- return (appRoles.roles.indexOf(roleName) >= 0)
+ return appRoles.roles.indexOf(roleName) >= 0;
}
- hasRealmRole (roleName: string): boolean {
- return (this.content.realm_access.roles.indexOf(roleName) >= 0)
+ /**
+ * Check whether the token contains a realm role.
+ *
+ * @param roleName - Realm role name to check
+ * @returns true when the role exists in the `realm_access.roles` claim
+ * @example
+ * token.hasRealmRole('admin') // => true | false
+ */
+ hasRealmRole(roleName: string): boolean {
+ if (this.content.realm_access == null || this.content.realm_access.roles == null) {
+ return false;
+ }
+ return this.content.realm_access.roles.indexOf(roleName) >= 0;
}
}
diff --git a/libs/index.ts b/libs/index.ts
index 2ad9d26..a74dfc0 100644
--- a/libs/index.ts
+++ b/libs/index.ts
@@ -1,3 +1,6 @@
+// Re-export the public API for the library. Consumers should import from
+// the package root (e.g. `import { Keycloak } from 'keycloak-backend'`) and
+// use the classes below.
export * from './Keycloak'
export * from './AccessToken'
export * from './Jwt'
diff --git a/package.json b/package.json
index c225628..fefb4e0 100644
--- a/package.json
+++ b/package.json
@@ -1,13 +1,23 @@
{
"name": "keycloak-backend",
- "version": "5.0.0",
+ "version": "5.1.0",
"description": "Keycloak Node.js minimalist connector for backend services integration. ",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"lint": "ts-standard",
"format": "ts-standard --fix",
- "test": "echo 'TODO'",
+ "test": "jest",
+ "test:watch": "jest --watch",
+ "test:coverage": "jest --coverage",
+ "test:integration": "npm run integration:up && npm run integration:wait && npm run integration:test; EXIT_CODE=$?; npm run integration:down; exit $EXIT_CODE",
+ "integration:up": "docker compose up -d",
+ "integration:down": "docker compose down",
+ "integration:clean": "docker compose down -v",
+ "integration:logs": "docker compose logs -f",
+ "integration:wait": "node tests/integration/wait-for-keycloak.js",
+ "integration:test": "jest --config jest.integration.config.js",
+ "actions": "DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}') act pull_request",
"build": "tsc",
"prepare": "npm run build"
},
@@ -35,12 +45,18 @@
},
"homepage": "https://github.com/jkyberneees/keycloak-backend#readme",
"dependencies": {
- "axios": "^1.3.3",
- "jsonwebtoken": "^9.0.0"
+ "axios": "^1.13.2",
+ "jsonwebtoken": "^9.0.2"
},
"devDependencies": {
- "@types/jsonwebtoken": "^9.0.1",
- "@types/node": "^18.11.18",
- "typescript": "^4.9.4"
+ "@types/axios-mock-adapter": "^1.10.4",
+ "@types/jest": "^30.0.0",
+ "@types/jsonwebtoken": "^9.0.10",
+ "@types/node": "^18.19.130",
+ "axios-mock-adapter": "^2.1.0",
+ "jest": "^30.2.0",
+ "ts-jest": "^29.4.5",
+ "ts-standard": "^12.0.2",
+ "typescript": "^4.9.5"
}
}
diff --git a/tests/AccessToken.test.ts b/tests/AccessToken.test.ts
new file mode 100644
index 0000000..fee0700
--- /dev/null
+++ b/tests/AccessToken.test.ts
@@ -0,0 +1,448 @@
+import { AccessToken } from "../libs/AccessToken";
+import { AxiosInstance } from "axios";
+import { stringify } from "querystring";
+
+describe("AccessToken", () => {
+ let mockClient: jest.Mocked;
+ let accessToken: AccessToken;
+ let mockConfig: any;
+ let onErrorSpy: jest.Mock;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ onErrorSpy = jest.fn();
+
+ mockConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ username: "testuser",
+ password: "testpass",
+ prefix: "",
+ onError: onErrorSpy,
+ };
+
+ mockClient = {
+ get: jest.fn(),
+ post: jest.fn(),
+ } as any;
+
+ accessToken = new AccessToken(mockConfig, mockClient);
+ });
+
+ describe("info", () => {
+ it("should retrieve user info with access token", async () => {
+ const token = "valid.access.token";
+ const mockUserInfo = { sub: "user-123", email: "user@example.org" };
+
+ mockClient.get.mockResolvedValue({ data: mockUserInfo });
+
+ const result = await accessToken.info(token);
+
+ expect(mockClient.get).toHaveBeenCalledWith("/realms/test-realm/protocol/openid-connect/userinfo", {
+ headers: {
+ Authorization: "Bearer valid.access.token",
+ },
+ });
+ expect(result).toEqual(mockUserInfo);
+ });
+
+ it("should use prefix for legacy endpoints", async () => {
+ const legacyConfig = { ...mockConfig, prefix: "/auth" };
+ const legacyAccessToken = new AccessToken(legacyConfig, mockClient);
+ const token = "valid.access.token";
+
+ mockClient.get.mockResolvedValue({ data: {} });
+
+ await legacyAccessToken.info(token);
+
+ expect(mockClient.get).toHaveBeenCalledWith(
+ "/auth/realms/test-realm/protocol/openid-connect/userinfo",
+ expect.any(Object)
+ );
+ });
+
+ it("should throw error on failed request", async () => {
+ const token = "invalid.token";
+ mockClient.get.mockRejectedValue(new Error("Unauthorized"));
+
+ await expect(accessToken.info(token)).rejects.toThrow("Unauthorized");
+ });
+
+ it("should work with config that has client_secret", async () => {
+ const configWithSecret = { ...mockConfig, client_secret: "secret123" };
+ const atWithSecret = new AccessToken(configWithSecret, mockClient);
+ const token = "valid.access.token";
+ const mockUserInfo = { sub: "user-123" };
+
+ mockClient.get.mockResolvedValue({ data: mockUserInfo });
+
+ const result = await atWithSecret.info(token);
+
+ expect(result).toEqual(mockUserInfo);
+ });
+ });
+
+ describe("refresh", () => {
+ it("should refresh token using refresh_token", async () => {
+ const refreshToken = "valid.refresh.token";
+ const mockResponse = {
+ data: {
+ access_token: "new.access.token",
+ refresh_token: "new.refresh.token",
+ },
+ };
+
+ mockClient.post.mockResolvedValue(mockResponse);
+
+ const result = await accessToken.refresh(refreshToken);
+
+ expect(mockClient.post).toHaveBeenCalledWith(
+ "/realms/test-realm/protocol/openid-connect/token",
+ stringify({
+ grant_type: "refresh_token",
+ client_id: "test-client",
+ refresh_token: refreshToken,
+ })
+ );
+ expect(result).toEqual(mockResponse);
+ });
+
+ it("should include client_secret when provided", async () => {
+ const configWithSecret = { ...mockConfig, client_secret: "super-secret" };
+ const accessTokenWithSecret = new AccessToken(configWithSecret, mockClient);
+ const refreshToken = "valid.refresh.token";
+
+ mockClient.post.mockResolvedValue({ data: {} });
+
+ await accessTokenWithSecret.refresh(refreshToken);
+
+ expect(mockClient.post).toHaveBeenCalledWith(
+ expect.any(String),
+ stringify({
+ grant_type: "refresh_token",
+ client_id: "test-client",
+ refresh_token: refreshToken,
+ client_secret: "super-secret",
+ })
+ );
+ });
+
+ it("should throw error on failed refresh", async () => {
+ const refreshToken = "invalid.refresh.token";
+ mockClient.post.mockRejectedValue(new Error("Invalid refresh token"));
+
+ await expect(accessToken.refresh(refreshToken)).rejects.toThrow("Invalid refresh token");
+ });
+ });
+
+ describe("get", () => {
+ it("should get new access token when data is null", async () => {
+ const mockResponse = {
+ data: {
+ access_token: "new.access.token",
+ refresh_token: "new.refresh.token",
+ },
+ };
+
+ mockClient.post.mockResolvedValue(mockResponse);
+
+ const result = await accessToken.get();
+
+ expect(mockClient.post).toHaveBeenCalledWith(
+ "/realms/test-realm/protocol/openid-connect/token",
+ stringify({
+ grant_type: "password",
+ client_id: "test-client",
+ username: "testuser",
+ password: "testpass",
+ scope: "openid",
+ })
+ );
+ expect(result).toBe("new.access.token");
+ });
+
+ it("should include scope when provided", async () => {
+ const mockResponse = {
+ data: {
+ access_token: "new.access.token",
+ refresh_token: "new.refresh.token",
+ },
+ };
+
+ mockClient.post.mockResolvedValue(mockResponse);
+
+ await accessToken.get("openid profile");
+
+ expect(mockClient.post).toHaveBeenCalledWith(
+ expect.any(String),
+ stringify({
+ grant_type: "password",
+ client_id: "test-client",
+ username: "testuser",
+ password: "testpass",
+ scope: "openid profile",
+ })
+ );
+ });
+
+ it("should use client_credentials grant when no username/password provided", async () => {
+ const configNoAuth = { ...mockConfig };
+ delete configNoAuth.username;
+ delete configNoAuth.password;
+ configNoAuth.client_secret = "super-secret";
+ const accessTokenNoAuth = new AccessToken(configNoAuth, mockClient);
+
+ mockClient.post.mockResolvedValue({
+ data: {
+ access_token: "new.access.token",
+ refresh_token: "new.refresh.token",
+ },
+ });
+
+ await accessTokenNoAuth.get();
+
+ expect(mockClient.post).toHaveBeenCalledWith(
+ expect.any(String),
+ stringify({
+ grant_type: "client_credentials",
+ client_id: "test-client",
+ client_secret: "super-secret",
+ scope: "openid",
+ })
+ );
+ });
+
+ it("should return cached token when still valid", async () => {
+ const mockResponse = {
+ data: {
+ access_token: "cached.access.token",
+ refresh_token: "cached.refresh.token",
+ },
+ };
+
+ mockClient.post.mockResolvedValue(mockResponse);
+ mockClient.get.mockResolvedValue({ data: { sub: "user-123" } });
+
+ // First call to populate cache
+ const firstToken = await accessToken.get();
+ expect(firstToken).toBe("cached.access.token");
+
+ // Second call should use cache
+ const secondToken = await accessToken.get();
+ expect(secondToken).toBe("cached.access.token");
+ expect(mockClient.post).toHaveBeenCalledTimes(1);
+ expect(mockClient.get).toHaveBeenCalledTimes(1);
+ });
+
+ it("should refresh token when cached token is invalid", async () => {
+ const initialResponse = {
+ data: {
+ access_token: "initial.access.token",
+ refresh_token: "initial.refresh.token",
+ },
+ };
+
+ const refreshResponse = {
+ data: {
+ access_token: "refreshed.access.token",
+ refresh_token: "refreshed.refresh.token",
+ },
+ };
+
+ mockClient.post.mockResolvedValueOnce(initialResponse).mockResolvedValueOnce(refreshResponse);
+
+ // First call to populate cache
+ await accessToken.get();
+
+ // Mock info call failing (token invalid)
+ mockClient.get.mockRejectedValueOnce(new Error("Token expired"));
+
+ // Second call should refresh token
+ const result = await accessToken.get();
+
+ expect(result).toBe("refreshed.access.token");
+ expect(mockClient.post).toHaveBeenCalledTimes(2);
+ expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:info");
+ });
+
+ it("should re-authenticate when refresh fails", async () => {
+ const initialResponse = {
+ data: {
+ access_token: "initial.access.token",
+ refresh_token: "initial.refresh.token",
+ },
+ };
+
+ const newAuthResponse = {
+ data: {
+ access_token: "new.auth.token",
+ refresh_token: "new.auth.refresh.token",
+ },
+ };
+
+ mockClient.post
+ .mockResolvedValueOnce(initialResponse)
+ .mockRejectedValueOnce(new Error("Refresh token expired"))
+ .mockResolvedValueOnce(newAuthResponse);
+
+ // First call to populate cache
+ await accessToken.get();
+
+ // Mock info and refresh failing
+ mockClient.get.mockRejectedValueOnce(new Error("Token expired"));
+
+ // Should re-authenticate
+ const result = await accessToken.get();
+
+ expect(result).toBe("new.auth.token");
+ expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:info");
+ expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:refresh");
+ });
+
+ it("should re-authenticate when no refresh token available", async () => {
+ const initialResponse = {
+ data: {
+ access_token: "initial.access.token",
+ refresh_token: null,
+ },
+ };
+
+ const newAuthResponse = {
+ data: {
+ access_token: "new.auth.token",
+ refresh_token: "new.auth.refresh.token",
+ },
+ };
+
+ mockClient.post
+ .mockResolvedValueOnce(initialResponse)
+ // Reauth
+ .mockResolvedValueOnce(newAuthResponse);
+
+ // First call to populate cache
+ await accessToken.get();
+
+ // Mock info failing so refresh path is taken
+ mockClient.get.mockRejectedValueOnce(new Error("Token expired"));
+
+ const result = await accessToken.get();
+
+ expect(result).toBe("new.auth.token");
+ expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:info");
+ expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:refresh");
+ });
+
+ it("should throw error when depth parameter equals maxRetries", async () => {
+ // Call with depth=3 (equals maxRetries)
+ await expect(accessToken.get(undefined, 3)).rejects.toThrow("Max authentication retry depth exceeded");
+ expect(onErrorSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ message: "Max authentication retry depth exceeded" }),
+ "AccessToken.get"
+ );
+ });
+
+ it("should throw error and log when token request fails", async () => {
+ mockClient.post.mockRejectedValue(new Error("Network error"));
+ await expect(accessToken.get()).rejects.toThrow("Network error");
+ expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:request");
+ });
+
+ it("should call onError callback on errors", async () => {
+ const initialResponse = {
+ data: {
+ access_token: "initial.access.token",
+ refresh_token: "initial.refresh.token",
+ },
+ };
+
+ mockClient.post.mockResolvedValueOnce(initialResponse);
+
+ // First call
+ await accessToken.get();
+
+ // Mock failures
+ const infoError = new Error("Info failed");
+ mockClient.get.mockRejectedValueOnce(infoError);
+ mockClient.post.mockRejectedValueOnce(new Error("Refresh failed"));
+ mockClient.post.mockResolvedValueOnce({
+ data: {
+ access_token: "new.token",
+ refresh_token: "new.refresh",
+ },
+ });
+
+ await accessToken.get();
+
+ expect(onErrorSpy).toHaveBeenCalled();
+ expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:info");
+ expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:refresh");
+ });
+
+ it("should not call onError when not configured", async () => {
+ const configNoError = { ...mockConfig };
+ delete configNoError.onError;
+ const accessTokenNoError = new AccessToken(configNoError, mockClient);
+
+ const response = {
+ data: {
+ access_token: "token",
+ refresh_token: "refresh",
+ },
+ };
+
+ mockClient.post.mockResolvedValueOnce(response);
+ mockClient.get.mockRejectedValueOnce(new Error("Failed"));
+ mockClient.post.mockRejectedValueOnce(new Error("Refresh failed"));
+ mockClient.post.mockResolvedValueOnce(response);
+
+ await accessTokenNoError.get();
+
+ // Should not throw even without onError callback
+ expect(mockClient.post).toHaveBeenCalled();
+ });
+
+ it("should handle non-Error objects in catch blocks", async () => {
+ const response = {
+ data: {
+ access_token: "token",
+ refresh_token: "refresh",
+ },
+ };
+
+ mockClient.post.mockResolvedValueOnce(response);
+
+ await accessToken.get();
+
+ mockClient.get.mockRejectedValueOnce("string error");
+ mockClient.post.mockRejectedValueOnce({ code: "ERROR" });
+ mockClient.post.mockResolvedValueOnce(response);
+
+ const result = await accessToken.get();
+
+ expect(result).toBe("token");
+ expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:info");
+ expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:refresh");
+ });
+
+ it("should preserve scope parameter during retry", async () => {
+ const response = {
+ data: {
+ access_token: "token",
+ refresh_token: "refresh",
+ },
+ };
+
+ mockClient.post.mockResolvedValueOnce(response);
+ mockClient.get.mockRejectedValueOnce(new Error("Failed"));
+ mockClient.post.mockRejectedValueOnce(new Error("Refresh failed"));
+ mockClient.post.mockResolvedValueOnce(response);
+
+ await accessToken.get("openid profile");
+
+ // Check that last call includes the scope (querystring uses %20 for spaces)
+ const lastCall = mockClient.post.mock.calls[mockClient.post.mock.calls.length - 1];
+ expect(lastCall[1]).toContain("scope=openid%20profile");
+ });
+ });
+});
diff --git a/tests/Jwt.test.ts b/tests/Jwt.test.ts
new file mode 100644
index 0000000..926604a
--- /dev/null
+++ b/tests/Jwt.test.ts
@@ -0,0 +1,147 @@
+import { Jwt } from "../libs/Jwt";
+import { Token } from "../libs/Token";
+import { AxiosInstance } from "axios";
+import { verify } from "jsonwebtoken";
+
+jest.mock("jsonwebtoken");
+jest.mock("../libs/Token");
+
+describe("Jwt", () => {
+ let mockRequest: jest.Mocked;
+ let jwt: Jwt;
+ const mockConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ prefix: "",
+ };
+
+ const mockVerify = verify as jest.MockedFunction;
+ const MockToken = Token as jest.MockedClass;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRequest = {
+ get: jest.fn(),
+ } as any;
+
+ jwt = new Jwt(mockConfig, mockRequest);
+ });
+
+ describe("verify", () => {
+ it("should verify token online and return Token instance", async () => {
+ const accessToken = "valid.access.token";
+ mockRequest.get.mockResolvedValue({ data: { sub: "user-123" } });
+
+ const result = await jwt.verify(accessToken);
+
+ expect(mockRequest.get).toHaveBeenCalledWith("/realms/test-realm/protocol/openid-connect/userinfo", {
+ headers: {
+ Authorization: "Bearer valid.access.token",
+ },
+ });
+ expect(MockToken).toHaveBeenCalledWith(accessToken);
+ expect(result).toBeInstanceOf(Token);
+ });
+
+ it("should use prefix for legacy endpoints", async () => {
+ const legacyJwt = new Jwt({ ...mockConfig, prefix: "/auth" }, mockRequest);
+ const accessToken = "valid.access.token";
+ mockRequest.get.mockResolvedValue({ data: { sub: "user-123" } });
+
+ await legacyJwt.verify(accessToken);
+
+ expect(mockRequest.get).toHaveBeenCalledWith(
+ "/auth/realms/test-realm/protocol/openid-connect/userinfo",
+ expect.any(Object)
+ );
+ });
+
+ it("should throw error when verification fails", async () => {
+ const accessToken = "invalid.access.token";
+ mockRequest.get.mockRejectedValue(new Error("Unauthorized"));
+
+ await expect(jwt.verify(accessToken)).rejects.toThrow("Unauthorized");
+ });
+ });
+
+ describe("verifyOffline", () => {
+ it("should verify token offline with default RS256 algorithm", async () => {
+ const accessToken = "valid.jwt.token";
+ const cert = "PUBLIC_CERT_CONTENT";
+
+ mockVerify.mockImplementation((token, secret, options, callback: any) => {
+ callback(null);
+ });
+
+ const result = await jwt.verifyOffline(accessToken, cert);
+
+ expect(mockVerify).toHaveBeenCalledWith(accessToken, cert, { algorithms: ["RS256"] }, expect.any(Function));
+ expect(MockToken).toHaveBeenCalledWith(accessToken);
+ expect(result).toBeInstanceOf(Token);
+ });
+
+ it("should allow custom options while preserving default algorithms", async () => {
+ const accessToken = "valid.jwt.token";
+ const cert = "PUBLIC_CERT_CONTENT";
+ const customOptions = { issuer: "https://keycloak.example.org" };
+
+ mockVerify.mockImplementation((token, secret, options, callback: any) => {
+ callback(null);
+ });
+
+ await jwt.verifyOffline(accessToken, cert, customOptions);
+
+ expect(mockVerify).toHaveBeenCalledWith(
+ accessToken,
+ cert,
+ { algorithms: ["RS256"], issuer: "https://keycloak.example.org" },
+ expect.any(Function)
+ );
+ });
+
+ it("should reject when verification fails", async () => {
+ const accessToken = "invalid.jwt.token";
+ const cert = "PUBLIC_CERT_CONTENT";
+
+ mockVerify.mockImplementation((token, secret, options, callback: any) => {
+ callback(new Error("Invalid signature"));
+ });
+
+ await expect(jwt.verifyOffline(accessToken, cert)).rejects.toThrow("Invalid signature");
+ });
+
+ it("should allow overriding algorithms in options", async () => {
+ const accessToken = "valid.jwt.token";
+ const cert = "PUBLIC_CERT_CONTENT";
+ const customOptions = { algorithms: ["RS512"] as any };
+
+ mockVerify.mockImplementation((token, secret, options, callback: any) => {
+ callback(null);
+ });
+
+ await jwt.verifyOffline(accessToken, cert, customOptions);
+
+ expect(mockVerify).toHaveBeenCalledWith(accessToken, cert, { algorithms: ["RS512"] }, expect.any(Function));
+ });
+ });
+
+ describe("decode", () => {
+ it("should decode token without verification", () => {
+ const accessToken = "some.jwt.token";
+
+ const result = jwt.decode(accessToken);
+
+ expect(MockToken).toHaveBeenCalledWith(accessToken);
+ expect(result).toBeInstanceOf(Token);
+ });
+
+ it("should not make any HTTP requests", () => {
+ const accessToken = "some.jwt.token";
+
+ jwt.decode(accessToken);
+
+ expect(mockRequest.get).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/tests/Keycloak.test.ts b/tests/Keycloak.test.ts
new file mode 100644
index 0000000..02e0205
--- /dev/null
+++ b/tests/Keycloak.test.ts
@@ -0,0 +1,210 @@
+import { Keycloak, IExternalConfig } from "../libs/Keycloak";
+import Axios from "axios";
+
+jest.mock("axios");
+jest.mock("../libs/AccessToken");
+jest.mock("../libs/Jwt");
+
+describe("Keycloak", () => {
+ const MockAxios = Axios as jest.Mocked;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ MockAxios.create.mockReturnValue({
+ get: jest.fn(),
+ post: jest.fn(),
+ } as any);
+ });
+
+ describe("constructor", () => {
+ it("should initialize with basic configuration", () => {
+ const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ username: "testuser",
+ password: "testpass",
+ };
+
+ const keycloak = new Keycloak(config);
+
+ expect(keycloak.accessToken).toBeDefined();
+ expect(keycloak.jwt).toBeDefined();
+ expect(MockAxios.create).toHaveBeenCalledWith({
+ baseURL: "https://keycloak.example.org",
+ timeout: 10000,
+ httpsAgent: undefined,
+ });
+ });
+
+ it("should use legacy endpoint prefix when is_legacy_endpoint is true", () => {
+ const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ is_legacy_endpoint: true,
+ };
+
+ new Keycloak(config);
+
+ expect(MockAxios.create).toHaveBeenCalledWith({
+ baseURL: "https://keycloak.example.org",
+ timeout: 10000,
+ httpsAgent: undefined,
+ });
+ });
+
+ it("should not use legacy prefix when is_legacy_endpoint is false", () => {
+ const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ is_legacy_endpoint: false,
+ };
+
+ new Keycloak(config);
+
+ expect(MockAxios.create).toHaveBeenCalledWith({
+ baseURL: "https://keycloak.example.org",
+ timeout: 10000,
+ httpsAgent: undefined,
+ });
+ });
+
+ it("should use custom timeout when provided", () => {
+ const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ timeout: 5000,
+ };
+
+ new Keycloak(config);
+
+ expect(MockAxios.create).toHaveBeenCalledWith({
+ baseURL: "https://keycloak.example.org",
+ timeout: 5000,
+ httpsAgent: undefined,
+ });
+ });
+
+ it("should use custom httpsAgent when provided", () => {
+ const customAgent = { rejectUnauthorized: true };
+ const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ httpsAgent: customAgent,
+ };
+
+ new Keycloak(config);
+
+ expect(MockAxios.create).toHaveBeenCalledWith({
+ baseURL: "https://keycloak.example.org",
+ timeout: 10000,
+ httpsAgent: customAgent,
+ });
+ });
+
+ it("should support client_secret for client credentials flow", () => {
+ const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ client_secret: "super-secret",
+ };
+
+ const keycloak = new Keycloak(config);
+
+ expect(keycloak.accessToken).toBeDefined();
+ expect(keycloak.jwt).toBeDefined();
+ });
+
+ it("should support onError callback", () => {
+ const onError = jest.fn();
+ const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ onError,
+ };
+
+ const keycloak = new Keycloak(config);
+
+ expect(keycloak.accessToken).toBeDefined();
+ });
+
+ it("should work with all optional parameters", () => {
+ const onError = jest.fn();
+ const httpsAgent = { rejectUnauthorized: true };
+ const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ username: "user",
+ password: "pass",
+ client_secret: "secret",
+ is_legacy_endpoint: true,
+ timeout: 15000,
+ httpsAgent,
+ onError,
+ };
+
+ const keycloak = new Keycloak(config);
+
+ expect(keycloak.accessToken).toBeDefined();
+ expect(keycloak.jwt).toBeDefined();
+ expect(MockAxios.create).toHaveBeenCalledWith({
+ baseURL: "https://keycloak.example.org",
+ timeout: 15000,
+ httpsAgent,
+ });
+ });
+
+ it("should have jwt and accessToken as readonly properties in TypeScript", () => {
+ const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ };
+
+ const keycloak = new Keycloak(config);
+
+ // Properties are readonly in TypeScript but not enforced at runtime
+ expect(keycloak.jwt).toBeDefined();
+ expect(keycloak.accessToken).toBeDefined();
+ });
+
+ it("should use timeout of 0 when explicitly set to 0", () => {
+ const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ timeout: 0,
+ };
+
+ new Keycloak(config);
+
+ expect(MockAxios.create).toHaveBeenCalledWith({
+ baseURL: "https://keycloak.example.org",
+ timeout: 0,
+ httpsAgent: undefined,
+ });
+ });
+
+ it("should preserve all config properties in internal config", () => {
+ const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: "https://keycloak.example.org",
+ client_id: "test-client",
+ username: "user",
+ password: "pass",
+ };
+
+ // This verifies the config is passed through correctly
+ new Keycloak(config);
+
+ expect(MockAxios.create).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/tests/Token.test.ts b/tests/Token.test.ts
new file mode 100644
index 0000000..3f6ac5f
--- /dev/null
+++ b/tests/Token.test.ts
@@ -0,0 +1,487 @@
+import { Token } from "../libs/Token";
+import { decode } from "jsonwebtoken";
+
+jest.mock("jsonwebtoken");
+
+describe("Token", () => {
+ const mockDecode = decode as jest.MockedFunction;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("constructor", () => {
+ it("should create a token with valid JWT payload", () => {
+ const mockPayload = {
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ email: "user@example.org",
+ preferred_username: "testuser",
+ realm_access: { roles: ["user", "admin"] },
+ resource_access: { "my-app": { roles: ["app-role"] } },
+ };
+
+ mockDecode.mockReturnValue(mockPayload);
+
+ const token = new Token("mock.jwt.token");
+
+ expect(token.token).toBe("mock.jwt.token");
+ expect(token.content).toMatchObject(mockPayload);
+ expect(mockDecode).toHaveBeenCalledWith("mock.jwt.token", { json: true });
+ });
+
+ it("should accept token with azp instead of aud", () => {
+ const mockPayload = {
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ azp: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ };
+
+ mockDecode.mockReturnValue(mockPayload);
+
+ const token = new Token("mock.jwt.token");
+
+ expect(token.content.aud).toBe("client-app");
+ });
+
+ it("should throw error for token missing required iss field", () => {
+ mockDecode.mockReturnValue({
+ sub: "user-123",
+ aud: "client-app",
+ exp: 1234567890,
+ iat: 1234567890,
+ });
+
+ expect(() => new Token("invalid.token")).toThrow("Invalid token");
+ });
+
+ it("should throw error for token missing required sub field", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ aud: "client-app",
+ exp: 1234567890,
+ iat: 1234567890,
+ });
+
+ expect(() => new Token("invalid.token")).toThrow("Invalid token");
+ });
+
+ it("should throw error for token missing required aud field", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ exp: 1234567890,
+ iat: 1234567890,
+ });
+
+ expect(() => new Token("invalid.token")).toThrow("Invalid token");
+ });
+
+ it("should throw error for token missing required exp field", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ iat: 1234567890,
+ });
+
+ expect(() => new Token("invalid.token")).toThrow("Invalid token");
+ });
+
+ it("should throw error for token missing required iat field", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: 1234567890,
+ });
+
+ expect(() => new Token("invalid.token")).toThrow("Invalid token");
+ });
+
+ it("should throw error when decode returns null", () => {
+ mockDecode.mockReturnValue(null);
+
+ expect(() => new Token("invalid.token")).toThrow("Invalid token");
+ });
+
+ it("should throw error when payload is undefined", () => {
+ mockDecode.mockReturnValue(undefined as any);
+
+ expect(() => new Token("invalid.token")).toThrow("Invalid token");
+ });
+
+ it("should throw error when all fields except iss are missing", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ });
+
+ expect(() => new Token("invalid.token")).toThrow("Invalid token");
+ });
+ });
+
+ describe("isExpired", () => {
+ it("should return false for non-expired token", () => {
+ const futureExp = Math.floor(Date.now() / 1000) + 3600;
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: futureExp,
+ iat: Math.floor(Date.now() / 1000),
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.isExpired()).toBe(false);
+ });
+
+ it("should return true for expired token", () => {
+ const pastExp = Math.floor(Date.now() / 1000) - 3600;
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: pastExp,
+ iat: Math.floor(Date.now() / 1000) - 7200,
+ });
+
+ const token = new Token("expired.token");
+
+ expect(token.isExpired()).toBe(true);
+ });
+
+ it("should return true for token expiring at current time", () => {
+ const nowExp = Math.floor(Date.now() / 1000);
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: nowExp,
+ iat: nowExp - 3600,
+ });
+
+ const token = new Token("expiring.token");
+
+ expect(token.isExpired()).toBe(true);
+ });
+ });
+
+ describe("hasRealmRole", () => {
+ it("should return true when user has the realm role", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ realm_access: { roles: ["user", "admin"] },
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasRealmRole("user")).toBe(true);
+ expect(token.hasRealmRole("admin")).toBe(true);
+ });
+
+ it("should return false when user does not have the realm role", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ realm_access: { roles: ["user"] },
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasRealmRole("admin")).toBe(false);
+ });
+
+ it("should return false when realm_access is undefined", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasRealmRole("user")).toBe(false);
+ });
+
+ it("should return false when realm_access.roles is undefined", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ realm_access: {},
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasRealmRole("user")).toBe(false);
+ });
+
+ it("should return false when realm_access is null", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ realm_access: null,
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasRealmRole("user")).toBe(false);
+ });
+ });
+
+ describe("hasApplicationRole", () => {
+ it("should return true when user has the application role", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ resource_access: {
+ "my-app": { roles: ["viewer", "editor"] },
+ },
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasApplicationRole("my-app", "viewer")).toBe(true);
+ expect(token.hasApplicationRole("my-app", "editor")).toBe(true);
+ });
+
+ it("should return false when user does not have the application role", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ resource_access: {
+ "my-app": { roles: ["viewer"] },
+ },
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasApplicationRole("my-app", "admin")).toBe(false);
+ });
+
+ it("should return false when application does not exist", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ resource_access: {
+ "my-app": { roles: ["viewer"] },
+ },
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasApplicationRole("other-app", "viewer")).toBe(false);
+ });
+
+ it("should return false when resource_access is undefined", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasApplicationRole("my-app", "viewer")).toBe(false);
+ });
+
+ it("should return false when resource_access is null", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ resource_access: null,
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasApplicationRole("my-app", "viewer")).toBe(false);
+ });
+
+ it("should return false when app roles is null", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ resource_access: {
+ "my-app": null,
+ },
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasApplicationRole("my-app", "viewer")).toBe(false);
+ });
+
+ it("should return false when app roles.roles is undefined", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ resource_access: {
+ "my-app": {},
+ },
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasApplicationRole("my-app", "viewer")).toBe(false);
+ });
+ });
+
+ describe("token content access", () => {
+ it("should expose all decoded token properties", () => {
+ const mockPayload = {
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ email: "test@example.com",
+ preferred_username: "testuser",
+ family_name: "Doe",
+ given_name: "John",
+ name: "John Doe",
+ email_verified: true,
+ custom_claim: "custom_value",
+ };
+
+ mockDecode.mockReturnValue(mockPayload);
+
+ const token = new Token("valid.token");
+
+ expect(token.content.iss).toBe("https://keycloak.example.org");
+ expect(token.content.sub).toBe("user-123");
+ expect(token.content.aud).toBe("client-app");
+ expect(token.content.email).toBe("test@example.com");
+ expect(token.content.preferred_username).toBe("testuser");
+ expect(token.content.family_name).toBe("Doe");
+ expect(token.content.given_name).toBe("John");
+ expect(token.content.name).toBe("John Doe");
+ expect(token.content.email_verified).toBe(true);
+ expect(token.content.custom_claim).toBe("custom_value");
+ });
+
+ it("should support aud as array", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: ["client-app-1", "client-app-2"],
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ });
+
+ const token = new Token("valid.token");
+
+ expect(Array.isArray(token.content.aud)).toBe(true);
+ expect(token.content.aud).toEqual(["client-app-1", "client-app-2"]);
+ });
+ });
+
+ describe("edge cases for realm_access and resource_access", () => {
+ it("should handle realm_access with null roles array", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ realm_access: { roles: null },
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasRealmRole("user")).toBe(false);
+ });
+
+ it("should handle resource_access with null app entry", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ resource_access: {
+ "my-app": null,
+ },
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasApplicationRole("my-app", "viewer")).toBe(false);
+ });
+
+ it("should handle resource_access with null roles in app", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ resource_access: {
+ "my-app": { roles: null },
+ },
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.hasApplicationRole("my-app", "viewer")).toBe(false);
+ });
+
+ it("should handle completely empty token with only required fields", () => {
+ mockDecode.mockReturnValue({
+ iss: "https://keycloak.example.org",
+ sub: "user-123",
+ aud: "client-app",
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ iat: Math.floor(Date.now() / 1000),
+ });
+
+ const token = new Token("valid.token");
+
+ expect(token.content.iss).toBe("https://keycloak.example.org");
+ expect(token.content.sub).toBe("user-123");
+ expect(token.content.realm_access).toBeUndefined();
+ expect(token.content.resource_access).toBeUndefined();
+ expect(token.hasRealmRole("user")).toBe(false);
+ expect(token.hasApplicationRole("app", "role")).toBe(false);
+ });
+ });
+});
diff --git a/tests/integration/README.md b/tests/integration/README.md
new file mode 100644
index 0000000..2370e07
--- /dev/null
+++ b/tests/integration/README.md
@@ -0,0 +1,258 @@
+# Integration Tests
+
+This directory contains end-to-end integration tests that validate the keycloak-backend library against a real Keycloak instance running in Docker.
+
+## Prerequisites
+
+- Docker and Docker Compose installed
+- Node.js 18+ installed
+- npm or yarn
+
+## Quick Start
+
+Run integration tests with a single command:
+
+```bash
+npm run test:integration
+```
+
+This will:
+
+1. Start Keycloak and PostgreSQL using Docker Compose
+2. Wait for Keycloak to be fully ready
+3. Run the integration test suite
+4. Clean up containers after tests complete
+
+## Manual Testing
+
+If you want more control over the test environment:
+
+### 1. Start Keycloak
+
+```bash
+npm run integration:up
+```
+
+This starts:
+
+- PostgreSQL database on port 5432
+- Keycloak on port 8080
+- Pre-configured test realm, client, and users
+
+### 2. Wait for Keycloak to be Ready
+
+```bash
+npm run integration:wait
+```
+
+This polls the Keycloak health endpoint until it's ready (up to 2 minutes).
+
+### 3. Run Integration Tests
+
+```bash
+npm run integration:test
+```
+
+### 4. View Logs (Optional)
+
+```bash
+npm run integration:logs
+```
+
+### 5. Stop and Clean Up
+
+```bash
+npm run integration:down
+```
+
+To remove all data including volumes:
+
+```bash
+npm run integration:clean
+```
+
+## Test Configuration
+
+The integration tests use the following configuration:
+
+- **Keycloak URL**: `http://localhost:8080`
+- **Realm**: `test-realm`
+- **Client ID**: `test-client`
+- **Client Secret**: `test-secret`
+- **Test Users**:
+ - `testuser` / `testpass` (regular user with `user` role)
+ - `admin` / `adminpass` (admin user with `admin` and `user` roles)
+
+### Accessing Keycloak Admin Console
+
+While tests are running, you can access the Keycloak admin console:
+
+- URL: http://localhost:8080
+- Username: `admin`
+- Password: `admin`
+
+## Test Coverage
+
+The integration tests cover:
+
+- ✅ **Token Generation**
+ - Client credentials grant
+ - Password grant (resource owner)
+ - Token caching and reuse
+- ✅ **Token Verification**
+ - Online verification (via Keycloak API)
+ - Offline verification (using public certificate)
+ - Token decoding without verification
+- ✅ **User Information**
+ - Retrieving user info from access token
+- ✅ **Role Management**
+ - Realm role verification
+ - Application/client role verification
+- ✅ **Token Lifecycle**
+ - Token expiry detection
+ - Token refresh
+- ✅ **Error Handling**
+ - Invalid credentials
+ - Invalid client secret
+ - Invalid realm
+ - Network timeouts
+- ✅ **Security Features**
+ - Custom timeout configuration
+ - Error callback handling
+- ✅ **Scope Management**
+ - Custom scope requests
+ - Offline access scope
+
+## Troubleshooting
+
+### Keycloak not starting
+
+If Keycloak fails to start, check:
+
+1. Ports 8080 and 5432 are not already in use:
+
+ ```bash
+ lsof -i :8080
+ lsof -i :5432
+ ```
+
+2. Docker has enough resources (at least 2GB RAM recommended)
+
+3. View container logs:
+ ```bash
+ npm run integration:logs
+ ```
+
+### Tests failing
+
+If tests fail:
+
+1. Ensure Keycloak is fully ready:
+
+ ```bash
+ npm run integration:wait
+ ```
+
+2. Check Keycloak logs for errors:
+
+ ```bash
+ docker compose logs keycloak
+ ```
+
+3. Verify realm configuration was imported:
+ - Visit http://localhost:8080
+ - Login as admin/admin
+ - Check if `test-realm` exists
+
+### Clean slate
+
+To start fresh:
+
+```bash
+npm run integration:clean
+npm run test:integration
+```
+
+## CI/CD Integration
+
+For CI/CD pipelines, use the all-in-one command:
+
+```bash
+npm run test:integration
+```
+
+Example GitHub Actions workflow:
+
+```yaml
+name: Integration Tests
+
+on: [push, pull_request]
+
+jobs:
+ integration:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "18"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run integration tests
+ run: npm run test:integration
+```
+
+## Architecture
+
+### Docker Compose Stack
+
+```
+┌─────────────────┐
+│ PostgreSQL │ Port 5432
+│ (Database) │
+└────────┬────────┘
+ │
+ │
+┌────────▼────────┐
+│ Keycloak │ Port 8080
+│ (Auth Server) │ - Admin: admin/admin
+└────────┬────────┘ - Health: /health/ready
+ │
+ │
+┌────────▼────────┐
+│ Test Suite │
+│ (Jest + TS) │
+└─────────────────┘
+```
+
+### Realm Configuration
+
+The realm is pre-configured via `realm-export.json`:
+
+- Realm: `test-realm`
+- Client: `test-client` (confidential)
+- Users: `testuser`, `admin`
+- Roles: `user`, `admin`, `client-user-role`, `client-admin-role`
+
+## Performance
+
+Typical test execution times:
+
+- Container startup: 30-60 seconds
+- Test suite execution: 30-45 seconds
+- **Total**: ~1-2 minutes
+
+## Security Notes
+
+⚠️ **Important**: This setup is for **testing only**. Never use these configurations in production:
+
+- Default admin credentials (admin/admin)
+- Weak client secrets
+- HTTP instead of HTTPS
+- Permissive CORS settings
+- Development mode (`start-dev`)
diff --git a/tests/integration/integration.test.ts b/tests/integration/integration.test.ts
new file mode 100644
index 0000000..c729ee2
--- /dev/null
+++ b/tests/integration/integration.test.ts
@@ -0,0 +1,292 @@
+import { Keycloak } from "../../libs/Keycloak";
+import { IExternalConfig } from "../../libs/Keycloak";
+// Integration test configuration
+const config: IExternalConfig = {
+ realm: "test-realm",
+ keycloak_base_url: process.env.KEYCLOAK_URL || "http://localhost:8080",
+ client_id: "test-client",
+ client_secret: "test-secret",
+ username: "testuser",
+ password: "testpass",
+};
+
+describe("Keycloak Integration Tests", () => {
+ let keycloak: Keycloak;
+
+ beforeAll(() => {
+ // Initialize Keycloak instance for all tests
+ keycloak = new Keycloak(config);
+ });
+
+ describe("AccessToken - Client Credentials Grant", () => {
+ it("should generate access token using client credentials", async () => {
+ const clientKeycloak = new Keycloak({
+ ...config,
+ username: undefined,
+ password: undefined,
+ });
+
+ const accessToken = await clientKeycloak.accessToken.get();
+ const token = clientKeycloak.jwt.decode(accessToken);
+
+ expect(accessToken).toBeDefined();
+ expect(typeof accessToken).toBe("string");
+ expect(token.content).toBeDefined();
+ expect(token.content.iss).toContain("test-realm");
+ expect(token.content.azp).toBe("test-client");
+ expect(token.isExpired()).toBe(false);
+ }, 30000);
+
+ it("should cache and reuse valid token", async () => {
+ const clientKeycloak = new Keycloak({
+ ...config,
+ username: undefined,
+ password: undefined,
+ });
+
+ const token1 = await clientKeycloak.accessToken.get();
+ const token2 = await clientKeycloak.accessToken.get();
+
+ expect(token1).toBe(token2);
+ }, 30000);
+ });
+
+ describe("AccessToken - Password Grant", () => {
+ it("should generate access token using password grant", async () => {
+ const accessToken = await keycloak.accessToken.get();
+ const token = keycloak.jwt.decode(accessToken);
+
+ expect(accessToken).toBeDefined();
+ expect(typeof accessToken).toBe("string");
+ expect(token.content).toBeDefined();
+ expect(token.content.iss).toContain("test-realm");
+ expect(token.content.preferred_username).toBe("testuser");
+ expect(token.isExpired()).toBe(false);
+ }, 30000);
+
+ it("should retrieve user information", async () => {
+ const accessToken = await keycloak.accessToken.get();
+ const userInfo = await keycloak.accessToken.info(accessToken);
+
+ expect(userInfo).toBeDefined();
+ expect(userInfo.preferred_username).toBe("testuser");
+ expect(userInfo.email).toBe("testuser@example.com");
+ expect(userInfo.given_name).toBe("Test");
+ expect(userInfo.family_name).toBe("User");
+ }, 30000);
+
+ it("should handle token refresh", async () => {
+ // Get initial token
+ const accessToken1 = await keycloak.accessToken.get();
+ const token1 = keycloak.jwt.decode(accessToken1);
+
+ // Force token to be considered expired by manipulating the internal state
+ // In real scenario, we would wait for expiration, but for testing we'll get a new instance
+ const newKeycloak = new Keycloak(config);
+ const accessToken2 = await newKeycloak.accessToken.get();
+ const token2 = newKeycloak.jwt.decode(accessToken2);
+
+ expect(accessToken1).toBeDefined();
+ expect(accessToken2).toBeDefined();
+ expect(token1.content.sub).toBe(token2.content.sub); // Same user
+ }, 30000);
+ });
+
+ describe("Token Role Verification", () => {
+ it("should verify realm roles", async () => {
+ const accessToken = await keycloak.accessToken.get();
+ const token = keycloak.jwt.decode(accessToken);
+
+ expect(token.hasRealmRole("user")).toBe(true);
+ expect(token.hasRealmRole("admin")).toBe(false);
+ expect(token.hasRealmRole("nonexistent")).toBe(false);
+ }, 30000);
+
+ it("should verify application roles", async () => {
+ const accessToken = await keycloak.accessToken.get();
+ const token = keycloak.jwt.decode(accessToken);
+
+ expect(token.hasApplicationRole("test-client", "client-user-role")).toBe(true);
+ expect(token.hasApplicationRole("test-client", "client-admin-role")).toBe(false);
+ expect(token.hasApplicationRole("test-client", "nonexistent")).toBe(false);
+ expect(token.hasApplicationRole("other-client", "some-role")).toBe(false);
+ }, 30000);
+
+ it("should verify admin user roles", async () => {
+ const adminKeycloak = new Keycloak({
+ ...config,
+ username: "admin",
+ password: "adminpass",
+ });
+
+ const accessToken = await adminKeycloak.accessToken.get();
+ const token = adminKeycloak.jwt.decode(accessToken);
+
+ expect(token.hasRealmRole("admin")).toBe(true);
+ expect(token.hasRealmRole("user")).toBe(true);
+ expect(token.hasApplicationRole("test-client", "client-admin-role")).toBe(true);
+ expect(token.hasApplicationRole("test-client", "client-user-role")).toBe(true);
+ }, 30000);
+ });
+
+ describe("JWT Online Verification", () => {
+ it("should verify valid token online", async () => {
+ const accessToken = await keycloak.accessToken.get();
+
+ const verified = await keycloak.jwt.verify(accessToken);
+
+ expect(verified).toBeDefined();
+ expect(verified.content.preferred_username).toBe("testuser");
+ }, 30000);
+
+ it("should decode token without verification", async () => {
+ const accessToken = await keycloak.accessToken.get();
+
+ const decoded = keycloak.jwt.decode(accessToken);
+
+ expect(decoded).toBeDefined();
+ expect(decoded.content.preferred_username).toBe("testuser");
+ }, 30000);
+
+ it("should reject invalid token", async () => {
+ const invalidToken =
+ "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.invalid";
+
+ await expect(keycloak.jwt.verify(invalidToken)).rejects.toThrow();
+ }, 30000);
+ });
+
+ describe("JWT Offline Verification", () => {
+ let publicKey: string;
+
+ beforeAll(async () => {
+ // Fetch public key from Keycloak
+ const axios = require("axios");
+ const realmUrl = `${config.keycloak_base_url}/realms/${config.realm}`;
+ const response = await axios.get(realmUrl);
+ publicKey = response.data.public_key;
+ });
+
+ it("should verify token offline with public key", async () => {
+ const accessToken = await keycloak.accessToken.get();
+
+ // Format public key as PEM
+ const publicKeyPem = `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`;
+ const verified = await keycloak.jwt.verifyOffline(accessToken, publicKeyPem);
+
+ expect(verified).toBeDefined();
+ expect(verified.content.preferred_username).toBe("testuser");
+ }, 30000);
+
+ it("should reject token with wrong public key", async () => {
+ const accessToken = await keycloak.accessToken.get();
+ const wrongKey = `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwrong
+-----END PUBLIC KEY-----`;
+
+ await expect(keycloak.jwt.verifyOffline(accessToken, wrongKey)).rejects.toThrow();
+ }, 30000);
+
+ it("should verify token expiry offline", async () => {
+ const accessToken = await keycloak.accessToken.get();
+
+ const publicKeyPem = `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`;
+ const verified = await keycloak.jwt.verifyOffline(accessToken, publicKeyPem);
+
+ expect(verified.isExpired()).toBe(false);
+ expect(verified.content.exp).toBeGreaterThan(Date.now() / 1000);
+ }, 30000);
+ });
+
+ describe("Token Expiry and Refresh", () => {
+ it("should detect token expiry", async () => {
+ const accessToken = await keycloak.accessToken.get();
+ const token = keycloak.jwt.decode(accessToken);
+
+ expect(token.isExpired()).toBe(false);
+
+ // Verify the expiration time is in the future
+ const currentTime = Math.floor(Date.now() / 1000);
+ expect(token.content.exp).toBeGreaterThan(currentTime);
+ }, 30000);
+ });
+
+ describe("Error Handling", () => {
+ it("should handle invalid credentials", async () => {
+ const invalidKeycloak = new Keycloak({
+ ...config,
+ username: "invalid",
+ password: "invalid",
+ });
+
+ await expect(invalidKeycloak.accessToken.get()).rejects.toThrow();
+ }, 30000);
+
+ it("should handle invalid client secret", async () => {
+ const invalidKeycloak = new Keycloak({
+ ...config,
+ client_secret: "invalid-secret",
+ username: undefined,
+ password: undefined,
+ });
+
+ await expect(invalidKeycloak.accessToken.get()).rejects.toThrow();
+ }, 30000);
+
+ it("should handle invalid realm", async () => {
+ const invalidKeycloak = new Keycloak({
+ ...config,
+ realm: "nonexistent-realm",
+ });
+
+ await expect(invalidKeycloak.accessToken.get()).rejects.toThrow();
+ }, 30000);
+ });
+
+ describe("Security Features", () => {
+ it("should respect timeout configuration", async () => {
+ const timeoutKeycloak = new Keycloak({
+ ...config,
+ timeout: 1, // 1ms timeout
+ });
+
+ // This should timeout
+ await expect(timeoutKeycloak.accessToken.get()).rejects.toThrow();
+ }, 30000);
+
+ it("should handle custom error callback", async () => {
+ const errors: Array<{ error: any; context: string }> = [];
+
+ const errorKeycloak = new Keycloak({
+ ...config,
+ username: "invalid",
+ password: "invalid",
+ onError: (error, context) => {
+ errors.push({ error, context });
+ },
+ });
+
+ await expect(errorKeycloak.accessToken.get()).rejects.toThrow();
+
+ expect(errors.length).toBeGreaterThan(0);
+ }, 30000);
+ });
+
+ describe("Multiple Scopes", () => {
+ it("should request token with custom scope", async () => {
+ const accessToken = await keycloak.accessToken.get("openid profile email");
+ const token = keycloak.jwt.decode(accessToken);
+
+ expect(accessToken).toBeDefined();
+ expect(token.content.preferred_username).toBe("testuser");
+ }, 30000);
+
+ it("should request token with offline_access scope", async () => {
+ const accessToken = await keycloak.accessToken.get("openid offline_access");
+ const token = keycloak.jwt.decode(accessToken);
+
+ expect(accessToken).toBeDefined();
+ expect(token.content.preferred_username).toBe("testuser");
+ }, 30000);
+ });
+});
diff --git a/tests/integration/realm-export.json b/tests/integration/realm-export.json
new file mode 100644
index 0000000..59d2ba6
--- /dev/null
+++ b/tests/integration/realm-export.json
@@ -0,0 +1,202 @@
+{
+ "id": "test-realm",
+ "realm": "test-realm",
+ "enabled": true,
+ "sslRequired": "external",
+ "registrationAllowed": false,
+ "resetPasswordAllowed": false,
+ "editUsernameAllowed": false,
+ "bruteForceProtected": false,
+ "accessTokenLifespan": 300,
+ "ssoSessionIdleTimeout": 1800,
+ "ssoSessionMaxLifespan": 36000,
+ "offlineSessionIdleTimeout": 2592000,
+ "accessCodeLifespan": 60,
+ "accessCodeLifespanUserAction": 300,
+ "accessCodeLifespanLogin": 1800,
+ "clients": [
+ {
+ "clientId": "test-client",
+ "enabled": true,
+ "clientAuthenticatorType": "client-secret",
+ "secret": "test-secret",
+ "redirectUris": ["*"],
+ "webOrigins": ["*"],
+ "protocol": "openid-connect",
+ "publicClient": false,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": true,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "fullScopeAllowed": true,
+ "attributes": {
+ "access.token.lifespan": "300"
+ },
+ "protocolMappers": [
+ {
+ "name": "username",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "preferred_username",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "email",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "email",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "given name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "firstName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "given_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "family name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "lastName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "family_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "realm roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "multivalued": "true",
+ "userinfo.token.claim": "true",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "realm_access.roles",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "client roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-client-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "multivalued": "true",
+ "userinfo.token.claim": "true",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "resource_access.${client_id}.roles",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "audience",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "consentRequired": false,
+ "config": {
+ "included.client.audience": "test-client",
+ "id.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ }
+ ]
+ }
+ ],
+ "users": [
+ {
+ "username": "testuser",
+ "enabled": true,
+ "email": "testuser@example.com",
+ "firstName": "Test",
+ "lastName": "User",
+ "emailVerified": true,
+ "credentials": [
+ {
+ "type": "password",
+ "value": "testpass",
+ "temporary": false
+ }
+ ],
+ "realmRoles": ["user", "offline_access"],
+ "clientRoles": {
+ "test-client": ["client-user-role"]
+ }
+ },
+ {
+ "username": "admin",
+ "enabled": true,
+ "email": "admin@example.com",
+ "firstName": "Admin",
+ "lastName": "User",
+ "emailVerified": true,
+ "credentials": [
+ {
+ "type": "password",
+ "value": "adminpass",
+ "temporary": false
+ }
+ ],
+ "realmRoles": ["admin", "user", "offline_access"],
+ "clientRoles": {
+ "test-client": ["client-admin-role", "client-user-role"]
+ }
+ }
+ ],
+ "roles": {
+ "realm": [
+ {
+ "name": "user",
+ "description": "User role"
+ },
+ {
+ "name": "admin",
+ "description": "Admin role"
+ },
+ {
+ "name": "offline_access",
+ "description": "Offline access"
+ }
+ ],
+ "client": {
+ "test-client": [
+ {
+ "name": "client-user-role",
+ "description": "Client user role"
+ },
+ {
+ "name": "client-admin-role",
+ "description": "Client admin role"
+ }
+ ]
+ }
+ }
+}
diff --git a/tests/integration/wait-for-keycloak.js b/tests/integration/wait-for-keycloak.js
new file mode 100644
index 0000000..7c494f2
--- /dev/null
+++ b/tests/integration/wait-for-keycloak.js
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+
+/**
+ * Wait for Keycloak to be ready before running integration tests.
+ * This script polls the Keycloak health endpoint until it responds successfully.
+ */
+
+const http = require("http");
+
+const KEYCLOAK_URL = process.env.KEYCLOAK_URL || "http://localhost:8080";
+const MAX_ATTEMPTS = 60; // 60 attempts
+const RETRY_DELAY = 2000; // 2 seconds
+
+function checkKeycloak(attempt = 1) {
+ return new Promise((resolve, reject) => {
+ // Check if the test realm is available, which confirms Keycloak is up and import finished
+ const url = new URL(`${KEYCLOAK_URL}/realms/test-realm`);
+
+ const req = http.get(
+ {
+ hostname: url.hostname,
+ port: url.port || 80,
+ path: url.pathname,
+ timeout: 5000,
+ },
+ (res) => {
+ if (res.statusCode === 200) {
+ console.log("✅ Keycloak is ready!");
+ resolve();
+ } else {
+ if (attempt >= MAX_ATTEMPTS) {
+ reject(new Error(`Keycloak not ready after ${MAX_ATTEMPTS} attempts`));
+ } else {
+ console.log(`⏳ Waiting for Keycloak... (attempt ${attempt}/${MAX_ATTEMPTS})`);
+ setTimeout(() => {
+ checkKeycloak(attempt + 1)
+ .then(resolve)
+ .catch(reject);
+ }, RETRY_DELAY);
+ }
+ }
+ }
+ );
+
+ req.on("error", (err) => {
+ if (attempt >= MAX_ATTEMPTS) {
+ reject(new Error(`Keycloak not ready after ${MAX_ATTEMPTS} attempts: ${err.message}`));
+ } else {
+ console.log(`⏳ Waiting for Keycloak... (attempt ${attempt}/${MAX_ATTEMPTS})`);
+ setTimeout(() => {
+ checkKeycloak(attempt + 1)
+ .then(resolve)
+ .catch(reject);
+ }, RETRY_DELAY);
+ }
+ });
+
+ req.on("timeout", () => {
+ req.destroy();
+ if (attempt >= MAX_ATTEMPTS) {
+ reject(new Error(`Keycloak not ready after ${MAX_ATTEMPTS} attempts: timeout`));
+ } else {
+ console.log(`⏳ Waiting for Keycloak... (attempt ${attempt}/${MAX_ATTEMPTS})`);
+ setTimeout(() => {
+ checkKeycloak(attempt + 1)
+ .then(resolve)
+ .catch(reject);
+ }, RETRY_DELAY);
+ }
+ });
+ });
+}
+
+console.log(`🔍 Checking Keycloak at ${KEYCLOAK_URL}...`);
+checkKeycloak()
+ .then(() => {
+ process.exit(0);
+ })
+ .catch((err) => {
+ console.error("❌ Error:", err.message);
+ process.exit(1);
+ });