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 + [![NPM version](https://badgen.net/npm/v/keycloak-backend)](https://www.npmjs.com/package/keycloak-backend) [![NPM Total Downloads](https://badgen.net/npm/dt/keycloak-backend)](https://www.npmjs.com/package/keycloak-backend) [![License](https://badgen.net/npm/license/keycloak-backend)](https://www.npmjs.com/package/keycloak-backend) [![TypeScript support](https://badgen.net/npm/types/keycloak-backend)](https://www.npmjs.com/package/keycloak-backend) [![Github stars](https://badgen.net/github/stars/BackendStack21/keycloak-backend?icon=github)](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); + });