├── .npmrc ├── .eslintignore ├── example ├── config-example.json ├── user-info.js ├── verify-token.js ├── verify-offline.js ├── refresh-token.js ├── retrieve-decode-token.js └── all.js ├── jest.integration.config.js ├── libs ├── index.ts ├── Keycloak.ts ├── Jwt.ts ├── Token.ts └── AccessToken.ts ├── .github └── workflows │ └── tests.yml ├── jest.config.js ├── LICENSE ├── .gitignore ├── docker-compose.yml ├── package.json ├── tests ├── integration │ ├── wait-for-keycloak.js │ ├── README.md │ ├── realm-export.json │ └── integration.test.ts ├── Jwt.test.ts ├── Keycloak.test.ts ├── AccessToken.test.ts └── Token.test.ts ├── README.md └── tsconfig.json /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | tests/ 2 | dist/ 3 | node_modules/ 4 | coverage/ 5 | -------------------------------------------------------------------------------- /example/config-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "realm": "realm-name", 3 | "keycloak_base_url": "https://keycloak.example.org", 4 | "client_id": "super-secure-client", 5 | "username": "user@example.org", 6 | "password": "passw0rd", 7 | "is_legacy_endpoint": false 8 | } -------------------------------------------------------------------------------- /jest.integration.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | roots: ["/tests/integration"], 5 | testMatch: ["**/integration.test.ts"], 6 | testTimeout: 30000, 7 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 8 | }; 9 | -------------------------------------------------------------------------------- /libs/index.ts: -------------------------------------------------------------------------------- 1 | // Re-export the public API for the library. Consumers should import from 2 | // the package root (e.g. `import { Keycloak } from 'keycloak-backend'`) and 3 | // use the classes below. 4 | export * from './Keycloak' 5 | export * from './AccessToken' 6 | export * from './Jwt' 7 | export * from './Token' 8 | -------------------------------------------------------------------------------- /example/user-info.js: -------------------------------------------------------------------------------- 1 | const config = require('../local/config-example') 2 | const Keycloak = require('../dist').Keycloak 3 | const keycloak = new Keycloak(config) 4 | 5 | keycloak.accessToken.get('openid').then(async (accessToken) => { 6 | const info = await keycloak.accessToken.info(accessToken) 7 | console.log(info) 8 | }) 9 | -------------------------------------------------------------------------------- /example/verify-token.js: -------------------------------------------------------------------------------- 1 | const config = require('../local/config-example') 2 | const Keycloak = require('../dist').Keycloak 3 | const keycloak = new Keycloak(config) 4 | 5 | keycloak.accessToken.get('openid').then(async (accessToken) => { 6 | const token = await keycloak.jwt.verify(accessToken) 7 | console.log(token.isExpired()) 8 | }) 9 | -------------------------------------------------------------------------------- /example/verify-offline.js: -------------------------------------------------------------------------------- 1 | const config = require('../local/config-example') 2 | const Keycloak = require('../dist').Keycloak 3 | const keycloak = new Keycloak(config) 4 | const fs = require('fs') 5 | 6 | const cert = fs.readFileSync('./local/public_cert.pem') 7 | 8 | keycloak.accessToken.get().then(async (accessToken) => { 9 | const token = await keycloak.jwt.verifyOffline(accessToken, cert) 10 | console.log(token.isExpired()) 11 | }) 12 | -------------------------------------------------------------------------------- /example/refresh-token.js: -------------------------------------------------------------------------------- 1 | const config = require('../local/config-example') 2 | const Keycloak = require('../dist').Keycloak 3 | const keycloak = new Keycloak(config) 4 | 5 | keycloak.accessToken.get().then(async (accessToken) => { 6 | // refresh operation is performed automatically on `keycloak.accessToken.get` 7 | const response = await keycloak.accessToken.refresh(keycloak.accessToken.data.refresh_token) 8 | console.log(response.data) 9 | }) 10 | -------------------------------------------------------------------------------- /example/retrieve-decode-token.js: -------------------------------------------------------------------------------- 1 | const config = require('../local/config-example') 2 | const Keycloak = require('../dist').Keycloak 3 | const keycloak = new Keycloak(config) 4 | 5 | keycloak.accessToken.get().then(async (accessToken) => { 6 | const token = keycloak.jwt.decode(accessToken) 7 | console.log({ expired: token.isExpired() }) 8 | console.log({ content: token.content }) 9 | console.log({ hasRole: token.hasRealmRole('my-role') }) 10 | console.log({ hasRole: token.hasApplicationRole('my-application', 'my-role') }) 11 | }) 12 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [main, master] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: "18" 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - name: Build library 25 | run: npm run build 26 | 27 | - name: Run unit tests 28 | run: npm run test:coverage 29 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | roots: ["/libs", "/tests"], 5 | testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"], 6 | testPathIgnorePatterns: ["/node_modules/", "/tests/integration/"], 7 | collectCoverageFrom: ["libs/**/*.ts", "!libs/**/*.d.ts", "!libs/index.ts"], 8 | coverageThreshold: { 9 | global: { 10 | branches: 90, 11 | functions: 100, 12 | lines: 100, 13 | statements: 100, 14 | }, 15 | }, 16 | coverageReporters: ["text", "lcov", "html"], 17 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 18 | }; 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Rolando Santamaria Maso 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | 3 | .idea 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (http://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # Typescript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # transpiled sources 65 | src/ 66 | 67 | .DS_Store 68 | 69 | *.pem 70 | 71 | local 72 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:15-alpine 6 | container_name: keycloak-postgres 7 | environment: 8 | POSTGRES_DB: keycloak 9 | POSTGRES_USER: keycloak 10 | POSTGRES_PASSWORD: keycloak 11 | ports: 12 | - "5432:5432" 13 | volumes: 14 | - postgres_data:/var/lib/postgresql/data 15 | healthcheck: 16 | test: ["CMD-SHELL", "pg_isready -U keycloak"] 17 | interval: 5s 18 | timeout: 5s 19 | retries: 5 20 | 21 | keycloak: 22 | image: quay.io/keycloak/keycloak:26.0 23 | container_name: keycloak-test 24 | environment: 25 | KC_DB: postgres 26 | KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak 27 | KC_DB_USERNAME: keycloak 28 | KC_DB_PASSWORD: keycloak 29 | KEYCLOAK_ADMIN: admin 30 | KEYCLOAK_ADMIN_PASSWORD: admin 31 | KC_HEALTH_ENABLED: true 32 | KC_METRICS_ENABLED: true 33 | KC_HTTP_ENABLED: true 34 | command: 35 | - start-dev 36 | - --import-realm 37 | ports: 38 | - "8080:8080" 39 | volumes: 40 | - ./tests/integration/realm-export.json:/opt/keycloak/data/import/realm-export.json 41 | depends_on: 42 | postgres: 43 | condition: service_healthy 44 | 45 | 46 | volumes: 47 | postgres_data: 48 | -------------------------------------------------------------------------------- /example/all.js: -------------------------------------------------------------------------------- 1 | const config = require('../local/config-example') 2 | const Keycloak = require('../dist').Keycloak 3 | const keycloak = new Keycloak(config) 4 | const fs = require('fs'); 5 | (async () => { 6 | try { 7 | // current version of Keycloak requires the openid scope for accessing user info endpoint 8 | const someAccessToken = await keycloak.accessToken.get('openid') 9 | // how to get openid info from access token... 10 | // info.sub contains the user id 11 | const info = await keycloak.accessToken.info(someAccessToken) 12 | console.log(info) 13 | 14 | // verify token online, intended for micro-service authorization 15 | let token = await keycloak.jwt.verify(someAccessToken) 16 | console.log(token.isExpired()) 17 | console.log(token.hasRealmRole('user')) 18 | console.log(token.hasApplicationRole('my-application', 'my-role')) 19 | 20 | // verify token offline, intended for micro-service authorization 21 | // using this method does not consider token invalidation, avoid long-term tokens here 22 | const cert = fs.readFileSync('./local/public_cert.pem') 23 | token = await keycloak.jwt.verifyOffline(someAccessToken, cert) 24 | console.log(token.isExpired()) 25 | // console.log(token.hasRealmRole('user')) 26 | // console.log(token.hasApplicationRole('my-application', 'my-role')) 27 | 28 | // how to manually refresh custom access token 29 | // (this operation is performed automatically for the service access token) 30 | const response = await keycloak.accessToken.refresh(keycloak.accessToken.data.refresh_token) 31 | console.log(response.data) 32 | } catch (err) { 33 | console.log(err) 34 | } 35 | })() 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "keycloak-backend", 3 | "version": "5.1.0", 4 | "description": "Keycloak Node.js minimalist connector for backend services integration. ", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "lint": "ts-standard", 9 | "format": "ts-standard --fix", 10 | "test": "jest", 11 | "test:watch": "jest --watch", 12 | "test:coverage": "jest --coverage", 13 | "test:integration": "npm run integration:up && npm run integration:wait && npm run integration:test; EXIT_CODE=$?; npm run integration:down; exit $EXIT_CODE", 14 | "integration:up": "docker compose up -d", 15 | "integration:down": "docker compose down", 16 | "integration:clean": "docker compose down -v", 17 | "integration:logs": "docker compose logs -f", 18 | "integration:wait": "node tests/integration/wait-for-keycloak.js", 19 | "integration:test": "jest --config jest.integration.config.js", 20 | "actions": "DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}') act pull_request", 21 | "build": "tsc", 22 | "prepare": "npm run build" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/jkyberneees/keycloak-backend.git" 27 | }, 28 | "keywords": [ 29 | "keycloak", 30 | "auth", 31 | "oauth", 32 | "jwt", 33 | "rest" 34 | ], 35 | "files": [ 36 | "LICENSE", 37 | "README.md", 38 | "libs/", 39 | "dist/" 40 | ], 41 | "author": "Rolando Santamaria Maso ", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/jkyberneees/keycloak-backend/issues" 45 | }, 46 | "homepage": "https://github.com/jkyberneees/keycloak-backend#readme", 47 | "dependencies": { 48 | "axios": "^1.13.2", 49 | "jsonwebtoken": "^9.0.2" 50 | }, 51 | "devDependencies": { 52 | "@types/axios-mock-adapter": "^1.10.4", 53 | "@types/jest": "^30.0.0", 54 | "@types/jsonwebtoken": "^9.0.10", 55 | "@types/node": "^18.19.130", 56 | "axios-mock-adapter": "^2.1.0", 57 | "jest": "^30.2.0", 58 | "ts-jest": "^29.4.5", 59 | "ts-standard": "^12.0.2", 60 | "typescript": "^4.9.5" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/integration/wait-for-keycloak.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Wait for Keycloak to be ready before running integration tests. 5 | * This script polls the Keycloak health endpoint until it responds successfully. 6 | */ 7 | 8 | const http = require("http"); 9 | 10 | const KEYCLOAK_URL = process.env.KEYCLOAK_URL || "http://localhost:8080"; 11 | const MAX_ATTEMPTS = 60; // 60 attempts 12 | const RETRY_DELAY = 2000; // 2 seconds 13 | 14 | function checkKeycloak(attempt = 1) { 15 | return new Promise((resolve, reject) => { 16 | // Check if the test realm is available, which confirms Keycloak is up and import finished 17 | const url = new URL(`${KEYCLOAK_URL}/realms/test-realm`); 18 | 19 | const req = http.get( 20 | { 21 | hostname: url.hostname, 22 | port: url.port || 80, 23 | path: url.pathname, 24 | timeout: 5000, 25 | }, 26 | (res) => { 27 | if (res.statusCode === 200) { 28 | console.log("✅ Keycloak is ready!"); 29 | resolve(); 30 | } else { 31 | if (attempt >= MAX_ATTEMPTS) { 32 | reject(new Error(`Keycloak not ready after ${MAX_ATTEMPTS} attempts`)); 33 | } else { 34 | console.log(`⏳ Waiting for Keycloak... (attempt ${attempt}/${MAX_ATTEMPTS})`); 35 | setTimeout(() => { 36 | checkKeycloak(attempt + 1) 37 | .then(resolve) 38 | .catch(reject); 39 | }, RETRY_DELAY); 40 | } 41 | } 42 | } 43 | ); 44 | 45 | req.on("error", (err) => { 46 | if (attempt >= MAX_ATTEMPTS) { 47 | reject(new Error(`Keycloak not ready after ${MAX_ATTEMPTS} attempts: ${err.message}`)); 48 | } else { 49 | console.log(`⏳ Waiting for Keycloak... (attempt ${attempt}/${MAX_ATTEMPTS})`); 50 | setTimeout(() => { 51 | checkKeycloak(attempt + 1) 52 | .then(resolve) 53 | .catch(reject); 54 | }, RETRY_DELAY); 55 | } 56 | }); 57 | 58 | req.on("timeout", () => { 59 | req.destroy(); 60 | if (attempt >= MAX_ATTEMPTS) { 61 | reject(new Error(`Keycloak not ready after ${MAX_ATTEMPTS} attempts: timeout`)); 62 | } else { 63 | console.log(`⏳ Waiting for Keycloak... (attempt ${attempt}/${MAX_ATTEMPTS})`); 64 | setTimeout(() => { 65 | checkKeycloak(attempt + 1) 66 | .then(resolve) 67 | .catch(reject); 68 | }, RETRY_DELAY); 69 | } 70 | }); 71 | }); 72 | } 73 | 74 | console.log(`🔍 Checking Keycloak at ${KEYCLOAK_URL}...`); 75 | checkKeycloak() 76 | .then(() => { 77 | process.exit(0); 78 | }) 79 | .catch((err) => { 80 | console.error("❌ Error:", err.message); 81 | process.exit(1); 82 | }); 83 | -------------------------------------------------------------------------------- /libs/Keycloak.ts: -------------------------------------------------------------------------------- 1 | import Axios from "axios"; 2 | import { AccessToken } from "./AccessToken"; 3 | import { Jwt } from "./Jwt"; 4 | 5 | /** 6 | * External configuration options accepted by the Keycloak client. 7 | * Users may pass only the fields from this interface; internals will 8 | * extend to `IInternalConfig` and shape additional fields at runtime. 9 | */ 10 | export interface IExternalConfig { 11 | realm: string; 12 | keycloak_base_url: string; 13 | client_id: string; 14 | username?: string; 15 | password?: string; 16 | client_secret?: string; 17 | is_legacy_endpoint?: boolean; 18 | timeout?: number; 19 | httpsAgent?: any; 20 | onError?: (error: Error, context: string) => void; 21 | } 22 | 23 | export interface IInternalConfig extends IExternalConfig { 24 | prefix: string; 25 | } 26 | 27 | /** 28 | * Main Keycloak entrypoint. Instantiates the HTTP client and exposes 29 | * helper instances for token lifecycle management (`AccessToken`) and 30 | * JWT verification (`Jwt`). 31 | */ 32 | export class Keycloak { 33 | public readonly jwt: Jwt; 34 | public readonly accessToken: AccessToken; 35 | 36 | /** 37 | * Construct a new Keycloak client instance. 38 | * 39 | * The instance provides `accessToken` and `jwt` helpers for programmatic 40 | * token management and verification. This class does not perform network 41 | * calls on construction. 42 | * 43 | * @param cfg - External configuration for Keycloak endpoints and credentials 44 | * @example 45 | * const keycloak = new Keycloak({ 46 | * realm: 'my-realm', 47 | * keycloak_base_url: 'https://keycloak.example.org', 48 | * client_id: 'my-client', 49 | * client_secret: 'super-secret' 50 | * }) 51 | * const token = await keycloak.accessToken.get() 52 | */ 53 | constructor(cfg: IExternalConfig) { 54 | // Build internal config from provided external config and compute 55 | // a `prefix` used to support legacy Keycloak endpoints. 56 | const icfg: IInternalConfig = { 57 | ...cfg, 58 | prefix: "", 59 | }; 60 | // Create an Axios HTTP client with sensible defaults. The `timeout` 61 | // is set to a defensive default of 10s, but can be overridden by the 62 | // user via `cfg.timeout`. An optional custom `httpsAgent` is also 63 | // supported for environments requiring custom TLS behavior. 64 | const client = Axios.create({ 65 | baseURL: icfg.keycloak_base_url, 66 | timeout: icfg.timeout ?? 10000, 67 | httpsAgent: icfg.httpsAgent, 68 | }); 69 | 70 | // When integration with Keycloak < 18 is required, a leading `/auth` 71 | // prefix must be used for realm endpoint paths. This conditionally 72 | // computes the correct `prefix` for all downstream client calls. 73 | if (icfg.is_legacy_endpoint === true) { 74 | icfg.prefix = "/auth"; 75 | } 76 | // Instantiate helper services with the configured HTTP client. 77 | this.accessToken = new AccessToken(icfg, client); 78 | this.jwt = new Jwt(icfg, client); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /libs/Jwt.ts: -------------------------------------------------------------------------------- 1 | import { Token } from "./Token"; 2 | import { verify, VerifyOptions } from "jsonwebtoken"; 3 | import { IInternalConfig } from "./index"; 4 | import { AxiosInstance } from "axios"; 5 | 6 | /** 7 | * JWT helper for verifying and decoding tokens. Supports both server-side 8 | * verification (online via Keycloak HTTP call) and offline signature 9 | * verification using a public certificate. 10 | */ 11 | export class Jwt { 12 | constructor(private readonly config: IInternalConfig, private readonly request: AxiosInstance) {} 13 | 14 | /** 15 | * Verify token offline using a public certificate. 16 | * Defaults to `RS256` algorithm allowed list for safety. 17 | * 18 | * @param accessToken - JWT string to be verified 19 | * @param cert - Public certificate or key used for verification 20 | * @param options - Optional jsonwebtoken VerifyOptions 21 | * @returns A Promise resolving to a `Token` instance if verification succeeds 22 | * @throws {Error} When verification fails (signature mismatch or invalid token) 23 | * @example 24 | * const token = await jwt.verifyOffline('ey...', pubKey) 25 | */ 26 | async verifyOffline(accessToken: string, cert: any, options?: VerifyOptions): Promise { 27 | const verifyOptions: VerifyOptions = { 28 | algorithms: ["RS256"], 29 | ...options, 30 | }; 31 | return await new Promise((resolve, reject) => { 32 | verify(accessToken, cert, verifyOptions, (err) => { 33 | if (err != null) reject(err); 34 | resolve(new Token(accessToken)); 35 | }); 36 | }); 37 | } 38 | 39 | /** 40 | * Decode a token into a `Token` wrapper without performing cryptographic 41 | * verification. Useful in contexts where the token will be inspected 42 | * but not trusted until verified by other means. 43 | * 44 | * @param accessToken - The JWT string to decode 45 | * @returns A `Token` instance containing the parsed payload 46 | * @example 47 | * const token = jwt.decode('ey...') 48 | */ 49 | decode(accessToken: string): Token { 50 | return new Token(accessToken); 51 | } 52 | 53 | /** 54 | * Online verification that performs a Keycloak server `userinfo` call 55 | * to make sure the token is still valid on the server-side. If the 56 | * call completes successfully the token is accepted and returned as a 57 | * `Token` wrapper for callers to inspect claims. 58 | * 59 | * @param accessToken - The JWT string to verify via Keycloak server 60 | * @returns A Promise resolving to a `Token` instance when userinfo succeeds 61 | * @throws {AxiosError} When the `userinfo` endpoint returns a non-2xx response 62 | * @example 63 | * const token = await jwt.verify('ey...') 64 | */ 65 | async verify(accessToken: string): Promise { 66 | await this.request.get(`${this.config.prefix}/realms/${this.config.realm}/protocol/openid-connect/userinfo`, { 67 | headers: { 68 | Authorization: "Bearer " + accessToken, 69 | }, 70 | }); 71 | 72 | return new Token(accessToken); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /libs/Token.ts: -------------------------------------------------------------------------------- 1 | import { decode } from "jsonwebtoken"; 2 | 3 | /** 4 | * Parsed JWT content. Most fields are the standard OIDC claims. Extra 5 | * fields are optional and intentionally flattened into the interface to 6 | * make them easily accessible when present. 7 | */ 8 | export interface ITokenContent { 9 | [key: string]: any; 10 | 11 | /** 12 | * Authorization server’s identifier 13 | */ 14 | iss: string; 15 | 16 | /** 17 | * User’s identifier 18 | */ 19 | sub: string; 20 | 21 | /** 22 | * Client’s identifier 23 | */ 24 | aud: string | string[]; 25 | 26 | /** 27 | * Expiration time of the ID token 28 | */ 29 | exp: number; 30 | 31 | /** 32 | * Time at which JWT was issued 33 | */ 34 | iat: number; 35 | 36 | family_name?: string; 37 | given_name?: string; 38 | name?: string; 39 | email?: string; 40 | preferred_username?: string; 41 | email_verified?: boolean; 42 | } 43 | 44 | /** 45 | * Wrapper around a raw token string that provides convenience methods and 46 | * a strongly-typed `content` object with the standard token claims. The 47 | * constructor decodes the JWT without verifying signatures — callers 48 | * should use `Jwt.verify` or `Jwt.verifyOffline` for verification. 49 | */ 50 | export class Token { 51 | public readonly token: string; 52 | public readonly content: ITokenContent; 53 | 54 | /** 55 | * Construct a Token wrapper around a raw JWT string. The constructor 56 | * decodes the payload (without verifying cryptographic signature). 57 | * Callers should verify tokens before trusting them. 58 | * 59 | * @param token - Raw JWT access token string 60 | * @throws {Error} when mandatory OIDC claims (`iss`, `sub`, `aud`, `exp`, `iat`) are missing 61 | * @example 62 | * const token = new Token('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...') 63 | */ 64 | constructor(token: string) { 65 | this.token = token; 66 | const payload = decode(this.token, { json: true }); 67 | // Basic structural validation: ensure the standard claims exist. 68 | // Note: Keycloak may use 'azp' (authorized party) instead of 'aud' for access tokens 69 | const aud = payload?.aud ?? payload?.azp; 70 | if ( 71 | payload?.iss !== undefined && 72 | payload?.sub !== undefined && 73 | aud !== undefined && 74 | payload?.exp !== undefined && 75 | payload?.iat !== undefined 76 | ) { 77 | this.content = { 78 | ...payload, 79 | iss: payload.iss, 80 | sub: payload.sub, 81 | aud: aud, 82 | exp: payload.exp, 83 | iat: payload.iat, 84 | }; 85 | } else { 86 | // If core OIDC claims are missing we don't attempt to work with the 87 | // token and instead fail fast with an explanatory error. 88 | throw new Error("Invalid token"); 89 | } 90 | } 91 | 92 | /** 93 | * Check whether the token has expired using the `exp` claim. 94 | * 95 | * @returns true when token is expired 96 | * @example 97 | * token.isExpired() // => true or false 98 | */ 99 | isExpired(): boolean { 100 | return this.content.exp * 1000 <= Date.now(); 101 | } 102 | 103 | /** 104 | * Check whether the token contains a role for a specific application 105 | * (client). Returns false if the claim is missing. 106 | * 107 | * @param appName - Client/application name 108 | * @param roleName - Role name to check 109 | * @returns true when the role exists for the application 110 | * @example 111 | * token.hasApplicationRole('my-app', 'viewer') // => true | false 112 | */ 113 | hasApplicationRole(appName: string, roleName: string): boolean { 114 | if (this.content.resource_access == null) { 115 | return false; 116 | } 117 | const appRoles = this.content.resource_access[appName]; 118 | if (appRoles == null || appRoles.roles == null) { 119 | return false; 120 | } 121 | 122 | return appRoles.roles.indexOf(roleName) >= 0; 123 | } 124 | 125 | /** 126 | * Check whether the token contains a realm role. 127 | * 128 | * @param roleName - Realm role name to check 129 | * @returns true when the role exists in the `realm_access.roles` claim 130 | * @example 131 | * token.hasRealmRole('admin') // => true | false 132 | */ 133 | hasRealmRole(roleName: string): boolean { 134 | if (this.content.realm_access == null || this.content.realm_access.roles == null) { 135 | return false; 136 | } 137 | return this.content.realm_access.roles.indexOf(roleName) >= 0; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/Jwt.test.ts: -------------------------------------------------------------------------------- 1 | import { Jwt } from "../libs/Jwt"; 2 | import { Token } from "../libs/Token"; 3 | import { AxiosInstance } from "axios"; 4 | import { verify } from "jsonwebtoken"; 5 | 6 | jest.mock("jsonwebtoken"); 7 | jest.mock("../libs/Token"); 8 | 9 | describe("Jwt", () => { 10 | let mockRequest: jest.Mocked; 11 | let jwt: Jwt; 12 | const mockConfig = { 13 | realm: "test-realm", 14 | keycloak_base_url: "https://keycloak.example.org", 15 | client_id: "test-client", 16 | prefix: "", 17 | }; 18 | 19 | const mockVerify = verify as jest.MockedFunction; 20 | const MockToken = Token as jest.MockedClass; 21 | 22 | beforeEach(() => { 23 | jest.clearAllMocks(); 24 | mockRequest = { 25 | get: jest.fn(), 26 | } as any; 27 | 28 | jwt = new Jwt(mockConfig, mockRequest); 29 | }); 30 | 31 | describe("verify", () => { 32 | it("should verify token online and return Token instance", async () => { 33 | const accessToken = "valid.access.token"; 34 | mockRequest.get.mockResolvedValue({ data: { sub: "user-123" } }); 35 | 36 | const result = await jwt.verify(accessToken); 37 | 38 | expect(mockRequest.get).toHaveBeenCalledWith("/realms/test-realm/protocol/openid-connect/userinfo", { 39 | headers: { 40 | Authorization: "Bearer valid.access.token", 41 | }, 42 | }); 43 | expect(MockToken).toHaveBeenCalledWith(accessToken); 44 | expect(result).toBeInstanceOf(Token); 45 | }); 46 | 47 | it("should use prefix for legacy endpoints", async () => { 48 | const legacyJwt = new Jwt({ ...mockConfig, prefix: "/auth" }, mockRequest); 49 | const accessToken = "valid.access.token"; 50 | mockRequest.get.mockResolvedValue({ data: { sub: "user-123" } }); 51 | 52 | await legacyJwt.verify(accessToken); 53 | 54 | expect(mockRequest.get).toHaveBeenCalledWith( 55 | "/auth/realms/test-realm/protocol/openid-connect/userinfo", 56 | expect.any(Object) 57 | ); 58 | }); 59 | 60 | it("should throw error when verification fails", async () => { 61 | const accessToken = "invalid.access.token"; 62 | mockRequest.get.mockRejectedValue(new Error("Unauthorized")); 63 | 64 | await expect(jwt.verify(accessToken)).rejects.toThrow("Unauthorized"); 65 | }); 66 | }); 67 | 68 | describe("verifyOffline", () => { 69 | it("should verify token offline with default RS256 algorithm", async () => { 70 | const accessToken = "valid.jwt.token"; 71 | const cert = "PUBLIC_CERT_CONTENT"; 72 | 73 | mockVerify.mockImplementation((token, secret, options, callback: any) => { 74 | callback(null); 75 | }); 76 | 77 | const result = await jwt.verifyOffline(accessToken, cert); 78 | 79 | expect(mockVerify).toHaveBeenCalledWith(accessToken, cert, { algorithms: ["RS256"] }, expect.any(Function)); 80 | expect(MockToken).toHaveBeenCalledWith(accessToken); 81 | expect(result).toBeInstanceOf(Token); 82 | }); 83 | 84 | it("should allow custom options while preserving default algorithms", async () => { 85 | const accessToken = "valid.jwt.token"; 86 | const cert = "PUBLIC_CERT_CONTENT"; 87 | const customOptions = { issuer: "https://keycloak.example.org" }; 88 | 89 | mockVerify.mockImplementation((token, secret, options, callback: any) => { 90 | callback(null); 91 | }); 92 | 93 | await jwt.verifyOffline(accessToken, cert, customOptions); 94 | 95 | expect(mockVerify).toHaveBeenCalledWith( 96 | accessToken, 97 | cert, 98 | { algorithms: ["RS256"], issuer: "https://keycloak.example.org" }, 99 | expect.any(Function) 100 | ); 101 | }); 102 | 103 | it("should reject when verification fails", async () => { 104 | const accessToken = "invalid.jwt.token"; 105 | const cert = "PUBLIC_CERT_CONTENT"; 106 | 107 | mockVerify.mockImplementation((token, secret, options, callback: any) => { 108 | callback(new Error("Invalid signature")); 109 | }); 110 | 111 | await expect(jwt.verifyOffline(accessToken, cert)).rejects.toThrow("Invalid signature"); 112 | }); 113 | 114 | it("should allow overriding algorithms in options", async () => { 115 | const accessToken = "valid.jwt.token"; 116 | const cert = "PUBLIC_CERT_CONTENT"; 117 | const customOptions = { algorithms: ["RS512"] as any }; 118 | 119 | mockVerify.mockImplementation((token, secret, options, callback: any) => { 120 | callback(null); 121 | }); 122 | 123 | await jwt.verifyOffline(accessToken, cert, customOptions); 124 | 125 | expect(mockVerify).toHaveBeenCalledWith(accessToken, cert, { algorithms: ["RS512"] }, expect.any(Function)); 126 | }); 127 | }); 128 | 129 | describe("decode", () => { 130 | it("should decode token without verification", () => { 131 | const accessToken = "some.jwt.token"; 132 | 133 | const result = jwt.decode(accessToken); 134 | 135 | expect(MockToken).toHaveBeenCalledWith(accessToken); 136 | expect(result).toBeInstanceOf(Token); 137 | }); 138 | 139 | it("should not make any HTTP requests", () => { 140 | const accessToken = "some.jwt.token"; 141 | 142 | jwt.decode(accessToken); 143 | 144 | expect(mockRequest.get).not.toHaveBeenCalled(); 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # keycloak-backend 2 | 3 | [![NPM version](https://badgen.net/npm/v/keycloak-backend)](https://www.npmjs.com/package/keycloak-backend) 4 | [![NPM Total Downloads](https://badgen.net/npm/dt/keycloak-backend)](https://www.npmjs.com/package/keycloak-backend) 5 | [![License](https://badgen.net/npm/license/keycloak-backend)](https://www.npmjs.com/package/keycloak-backend) 6 | [![TypeScript support](https://badgen.net/npm/types/keycloak-backend)](https://www.npmjs.com/package/keycloak-backend) 7 | [![Github stars](https://badgen.net/github/stars/BackendStack21/keycloak-backend?icon=github)](https://github.com/BackendStack21/keycloak-backend.git) 8 | 9 | 10 | 11 | Keycloak Node.js minimalist connector for backend services integration. It aims to serve as base for high performance authorization middlewares. 12 | 13 | > In order to use this module, the used Keycloak client `Direct Access Grants Enabled` setting should be `ON` 14 | 15 | ## Keycloak Introduction 16 | 17 | The awesome open-source Identity and Access Management solution develop by RedHat. 18 | Keycloak support those very nice features you are looking for: 19 | 20 | - Single-Sign On 21 | - LDAP and Active Directory 22 | - Standard Protocols 23 | - Social Login 24 | - Clustering 25 | - Custom Themes 26 | - Centralized Management 27 | - Identity Brokering 28 | - Extensible 29 | - Adapters 30 | - High Performance 31 | - Password Policies 32 | 33 | More about Keycloak: http://www.keycloak.org/ 34 | 35 | ### Compatibility 36 | 37 | This library is tested against **Keycloak 26.0**. It supports modern Keycloak versions (18+) by default. 38 | For versions older than 18, set `is_legacy_endpoint: true`. 39 | 40 | ## Using the keycloak-backend module 41 | 42 | ### Configuration 43 | 44 | ```js 45 | const Keycloak = require('keycloak-backend').Keycloak 46 | const keycloak = new Keycloak({ 47 | "realm": "realm-name", 48 | "keycloak_base_url": "https://keycloak.example.org", 49 | "client_id": "super-secure-client", 50 | "client_secret": "super-secure-secret", // Optional: for client_credentials grant 51 | "username": "user@example.org", // Optional: for password grant 52 | "password": "passw0rd", // Optional: for password grant 53 | "is_legacy_endpoint": false, // Optional: true for Keycloak < 18 54 | "timeout": 10000, // Optional: HTTP request timeout in ms (default: 10000) 55 | "httpsAgent": new https.Agent({ ... }), // Optional: Custom HTTPS agent 56 | "onError": (err, ctx) => console.error(ctx, err) // Optional: Error handler hook 57 | }) 58 | ``` 59 | 60 | > The `is_legacy_endpoint` configuration property should be TRUE for older Keycloak versions (under 18) 61 | 62 | For TypeScript: 63 | 64 | ```ts 65 | import { Keycloak } from "keycloak-backend"; 66 | const keycloak = new Keycloak({ 67 | realm: "realm-name", 68 | keycloak_base_url: "https://keycloak.example.org", 69 | client_id: "super-secure-client", 70 | // ... other options 71 | }); 72 | ``` 73 | 74 | ### Generating access tokens 75 | 76 | ```js 77 | const accessToken = await keycloak.accessToken.get(); 78 | ``` 79 | 80 | Or: 81 | 82 | ```js 83 | request.get("http://service.example.org/api/endpoint", { 84 | auth: { 85 | bearer: await keycloak.accessToken.get(), 86 | }, 87 | }); 88 | ``` 89 | 90 | ### Validating access tokens 91 | 92 | #### Online validation 93 | 94 | 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: 95 | 96 | ```js 97 | const token = await keycloak.jwt.verify(accessToken); 98 | //console.log(token.isExpired()) 99 | //console.log(token.hasRealmRole('user')) 100 | //console.log(token.hasApplicationRole('app-client-name', 'some-role')) 101 | ``` 102 | 103 | #### Offline validation 104 | 105 | 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: 106 | 107 | ```js 108 | // Ensure your public key is in PEM format 109 | const cert = `-----BEGIN PUBLIC KEY----- 110 | ...your public key... 111 | -----END PUBLIC KEY-----`; 112 | const token = await keycloak.jwt.verifyOffline(accessToken, cert); 113 | //console.log(token.isExpired()) 114 | //console.log(token.hasRealmRole('user')) 115 | //console.log(token.hasApplicationRole('app-client-name', 'some-role')) 116 | ``` 117 | 118 | ## Testing 119 | 120 | The project includes a comprehensive integration test suite that runs against a real Keycloak instance using Docker Compose. 121 | 122 | To run the integration tests: 123 | 124 | ```bash 125 | npm run test:integration 126 | ``` 127 | 128 | This will: 129 | 130 | 1. Spin up a Keycloak 26 container and a Postgres database. 131 | 2. Import a test realm with pre-configured clients and users. 132 | 3. Run the test suite to verify token generation, validation (online/offline), and role checks. 133 | 4. Tear down the environment. 134 | 135 | ## Breaking changes 136 | 137 | ### v4 138 | 139 | - Codebase migrated from JavaScript to TypeScript. Many thanks to @neferin12 140 | 141 | ### v3 142 | 143 | - The `UserManager` class was dropped 144 | - The `auth-server-url` config property was changed to `keycloak_base_url` 145 | - Most recent Keycloak API is supported by default, old versions are still supported through the `is_legacy_endpoint` config property 146 | -------------------------------------------------------------------------------- /tests/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | This directory contains end-to-end integration tests that validate the keycloak-backend library against a real Keycloak instance running in Docker. 4 | 5 | ## Prerequisites 6 | 7 | - Docker and Docker Compose installed 8 | - Node.js 18+ installed 9 | - npm or yarn 10 | 11 | ## Quick Start 12 | 13 | Run integration tests with a single command: 14 | 15 | ```bash 16 | npm run test:integration 17 | ``` 18 | 19 | This will: 20 | 21 | 1. Start Keycloak and PostgreSQL using Docker Compose 22 | 2. Wait for Keycloak to be fully ready 23 | 3. Run the integration test suite 24 | 4. Clean up containers after tests complete 25 | 26 | ## Manual Testing 27 | 28 | If you want more control over the test environment: 29 | 30 | ### 1. Start Keycloak 31 | 32 | ```bash 33 | npm run integration:up 34 | ``` 35 | 36 | This starts: 37 | 38 | - PostgreSQL database on port 5432 39 | - Keycloak on port 8080 40 | - Pre-configured test realm, client, and users 41 | 42 | ### 2. Wait for Keycloak to be Ready 43 | 44 | ```bash 45 | npm run integration:wait 46 | ``` 47 | 48 | This polls the Keycloak health endpoint until it's ready (up to 2 minutes). 49 | 50 | ### 3. Run Integration Tests 51 | 52 | ```bash 53 | npm run integration:test 54 | ``` 55 | 56 | ### 4. View Logs (Optional) 57 | 58 | ```bash 59 | npm run integration:logs 60 | ``` 61 | 62 | ### 5. Stop and Clean Up 63 | 64 | ```bash 65 | npm run integration:down 66 | ``` 67 | 68 | To remove all data including volumes: 69 | 70 | ```bash 71 | npm run integration:clean 72 | ``` 73 | 74 | ## Test Configuration 75 | 76 | The integration tests use the following configuration: 77 | 78 | - **Keycloak URL**: `http://localhost:8080` 79 | - **Realm**: `test-realm` 80 | - **Client ID**: `test-client` 81 | - **Client Secret**: `test-secret` 82 | - **Test Users**: 83 | - `testuser` / `testpass` (regular user with `user` role) 84 | - `admin` / `adminpass` (admin user with `admin` and `user` roles) 85 | 86 | ### Accessing Keycloak Admin Console 87 | 88 | While tests are running, you can access the Keycloak admin console: 89 | 90 | - URL: http://localhost:8080 91 | - Username: `admin` 92 | - Password: `admin` 93 | 94 | ## Test Coverage 95 | 96 | The integration tests cover: 97 | 98 | - ✅ **Token Generation** 99 | - Client credentials grant 100 | - Password grant (resource owner) 101 | - Token caching and reuse 102 | - ✅ **Token Verification** 103 | - Online verification (via Keycloak API) 104 | - Offline verification (using public certificate) 105 | - Token decoding without verification 106 | - ✅ **User Information** 107 | - Retrieving user info from access token 108 | - ✅ **Role Management** 109 | - Realm role verification 110 | - Application/client role verification 111 | - ✅ **Token Lifecycle** 112 | - Token expiry detection 113 | - Token refresh 114 | - ✅ **Error Handling** 115 | - Invalid credentials 116 | - Invalid client secret 117 | - Invalid realm 118 | - Network timeouts 119 | - ✅ **Security Features** 120 | - Custom timeout configuration 121 | - Error callback handling 122 | - ✅ **Scope Management** 123 | - Custom scope requests 124 | - Offline access scope 125 | 126 | ## Troubleshooting 127 | 128 | ### Keycloak not starting 129 | 130 | If Keycloak fails to start, check: 131 | 132 | 1. Ports 8080 and 5432 are not already in use: 133 | 134 | ```bash 135 | lsof -i :8080 136 | lsof -i :5432 137 | ``` 138 | 139 | 2. Docker has enough resources (at least 2GB RAM recommended) 140 | 141 | 3. View container logs: 142 | ```bash 143 | npm run integration:logs 144 | ``` 145 | 146 | ### Tests failing 147 | 148 | If tests fail: 149 | 150 | 1. Ensure Keycloak is fully ready: 151 | 152 | ```bash 153 | npm run integration:wait 154 | ``` 155 | 156 | 2. Check Keycloak logs for errors: 157 | 158 | ```bash 159 | docker compose logs keycloak 160 | ``` 161 | 162 | 3. Verify realm configuration was imported: 163 | - Visit http://localhost:8080 164 | - Login as admin/admin 165 | - Check if `test-realm` exists 166 | 167 | ### Clean slate 168 | 169 | To start fresh: 170 | 171 | ```bash 172 | npm run integration:clean 173 | npm run test:integration 174 | ``` 175 | 176 | ## CI/CD Integration 177 | 178 | For CI/CD pipelines, use the all-in-one command: 179 | 180 | ```bash 181 | npm run test:integration 182 | ``` 183 | 184 | Example GitHub Actions workflow: 185 | 186 | ```yaml 187 | name: Integration Tests 188 | 189 | on: [push, pull_request] 190 | 191 | jobs: 192 | integration: 193 | runs-on: ubuntu-latest 194 | 195 | steps: 196 | - uses: actions/checkout@v4 197 | 198 | - name: Setup Node.js 199 | uses: actions/setup-node@v4 200 | with: 201 | node-version: "18" 202 | 203 | - name: Install dependencies 204 | run: npm ci 205 | 206 | - name: Run integration tests 207 | run: npm run test:integration 208 | ``` 209 | 210 | ## Architecture 211 | 212 | ### Docker Compose Stack 213 | 214 | ``` 215 | ┌─────────────────┐ 216 | │ PostgreSQL │ Port 5432 217 | │ (Database) │ 218 | └────────┬────────┘ 219 | │ 220 | │ 221 | ┌────────▼────────┐ 222 | │ Keycloak │ Port 8080 223 | │ (Auth Server) │ - Admin: admin/admin 224 | └────────┬────────┘ - Health: /health/ready 225 | │ 226 | │ 227 | ┌────────▼────────┐ 228 | │ Test Suite │ 229 | │ (Jest + TS) │ 230 | └─────────────────┘ 231 | ``` 232 | 233 | ### Realm Configuration 234 | 235 | The realm is pre-configured via `realm-export.json`: 236 | 237 | - Realm: `test-realm` 238 | - Client: `test-client` (confidential) 239 | - Users: `testuser`, `admin` 240 | - Roles: `user`, `admin`, `client-user-role`, `client-admin-role` 241 | 242 | ## Performance 243 | 244 | Typical test execution times: 245 | 246 | - Container startup: 30-60 seconds 247 | - Test suite execution: 30-45 seconds 248 | - **Total**: ~1-2 minutes 249 | 250 | ## Security Notes 251 | 252 | ⚠️ **Important**: This setup is for **testing only**. Never use these configurations in production: 253 | 254 | - Default admin credentials (admin/admin) 255 | - Weak client secrets 256 | - HTTP instead of HTTPS 257 | - Permissive CORS settings 258 | - Development mode (`start-dev`) 259 | -------------------------------------------------------------------------------- /tests/integration/realm-export.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "test-realm", 3 | "realm": "test-realm", 4 | "enabled": true, 5 | "sslRequired": "external", 6 | "registrationAllowed": false, 7 | "resetPasswordAllowed": false, 8 | "editUsernameAllowed": false, 9 | "bruteForceProtected": false, 10 | "accessTokenLifespan": 300, 11 | "ssoSessionIdleTimeout": 1800, 12 | "ssoSessionMaxLifespan": 36000, 13 | "offlineSessionIdleTimeout": 2592000, 14 | "accessCodeLifespan": 60, 15 | "accessCodeLifespanUserAction": 300, 16 | "accessCodeLifespanLogin": 1800, 17 | "clients": [ 18 | { 19 | "clientId": "test-client", 20 | "enabled": true, 21 | "clientAuthenticatorType": "client-secret", 22 | "secret": "test-secret", 23 | "redirectUris": ["*"], 24 | "webOrigins": ["*"], 25 | "protocol": "openid-connect", 26 | "publicClient": false, 27 | "directAccessGrantsEnabled": true, 28 | "serviceAccountsEnabled": true, 29 | "standardFlowEnabled": true, 30 | "implicitFlowEnabled": false, 31 | "fullScopeAllowed": true, 32 | "attributes": { 33 | "access.token.lifespan": "300" 34 | }, 35 | "protocolMappers": [ 36 | { 37 | "name": "username", 38 | "protocol": "openid-connect", 39 | "protocolMapper": "oidc-usermodel-property-mapper", 40 | "consentRequired": false, 41 | "config": { 42 | "userinfo.token.claim": "true", 43 | "user.attribute": "username", 44 | "id.token.claim": "true", 45 | "access.token.claim": "true", 46 | "claim.name": "preferred_username", 47 | "jsonType.label": "String" 48 | } 49 | }, 50 | { 51 | "name": "email", 52 | "protocol": "openid-connect", 53 | "protocolMapper": "oidc-usermodel-property-mapper", 54 | "consentRequired": false, 55 | "config": { 56 | "userinfo.token.claim": "true", 57 | "user.attribute": "email", 58 | "id.token.claim": "true", 59 | "access.token.claim": "true", 60 | "claim.name": "email", 61 | "jsonType.label": "String" 62 | } 63 | }, 64 | { 65 | "name": "given name", 66 | "protocol": "openid-connect", 67 | "protocolMapper": "oidc-usermodel-property-mapper", 68 | "consentRequired": false, 69 | "config": { 70 | "userinfo.token.claim": "true", 71 | "user.attribute": "firstName", 72 | "id.token.claim": "true", 73 | "access.token.claim": "true", 74 | "claim.name": "given_name", 75 | "jsonType.label": "String" 76 | } 77 | }, 78 | { 79 | "name": "family name", 80 | "protocol": "openid-connect", 81 | "protocolMapper": "oidc-usermodel-property-mapper", 82 | "consentRequired": false, 83 | "config": { 84 | "userinfo.token.claim": "true", 85 | "user.attribute": "lastName", 86 | "id.token.claim": "true", 87 | "access.token.claim": "true", 88 | "claim.name": "family_name", 89 | "jsonType.label": "String" 90 | } 91 | }, 92 | { 93 | "name": "realm roles", 94 | "protocol": "openid-connect", 95 | "protocolMapper": "oidc-usermodel-realm-role-mapper", 96 | "consentRequired": false, 97 | "config": { 98 | "multivalued": "true", 99 | "userinfo.token.claim": "true", 100 | "id.token.claim": "true", 101 | "access.token.claim": "true", 102 | "claim.name": "realm_access.roles", 103 | "jsonType.label": "String" 104 | } 105 | }, 106 | { 107 | "name": "client roles", 108 | "protocol": "openid-connect", 109 | "protocolMapper": "oidc-usermodel-client-role-mapper", 110 | "consentRequired": false, 111 | "config": { 112 | "multivalued": "true", 113 | "userinfo.token.claim": "true", 114 | "id.token.claim": "true", 115 | "access.token.claim": "true", 116 | "claim.name": "resource_access.${client_id}.roles", 117 | "jsonType.label": "String" 118 | } 119 | }, 120 | { 121 | "name": "audience", 122 | "protocol": "openid-connect", 123 | "protocolMapper": "oidc-audience-mapper", 124 | "consentRequired": false, 125 | "config": { 126 | "included.client.audience": "test-client", 127 | "id.token.claim": "true", 128 | "access.token.claim": "true" 129 | } 130 | } 131 | ] 132 | } 133 | ], 134 | "users": [ 135 | { 136 | "username": "testuser", 137 | "enabled": true, 138 | "email": "testuser@example.com", 139 | "firstName": "Test", 140 | "lastName": "User", 141 | "emailVerified": true, 142 | "credentials": [ 143 | { 144 | "type": "password", 145 | "value": "testpass", 146 | "temporary": false 147 | } 148 | ], 149 | "realmRoles": ["user", "offline_access"], 150 | "clientRoles": { 151 | "test-client": ["client-user-role"] 152 | } 153 | }, 154 | { 155 | "username": "admin", 156 | "enabled": true, 157 | "email": "admin@example.com", 158 | "firstName": "Admin", 159 | "lastName": "User", 160 | "emailVerified": true, 161 | "credentials": [ 162 | { 163 | "type": "password", 164 | "value": "adminpass", 165 | "temporary": false 166 | } 167 | ], 168 | "realmRoles": ["admin", "user", "offline_access"], 169 | "clientRoles": { 170 | "test-client": ["client-admin-role", "client-user-role"] 171 | } 172 | } 173 | ], 174 | "roles": { 175 | "realm": [ 176 | { 177 | "name": "user", 178 | "description": "User role" 179 | }, 180 | { 181 | "name": "admin", 182 | "description": "Admin role" 183 | }, 184 | { 185 | "name": "offline_access", 186 | "description": "Offline access" 187 | } 188 | ], 189 | "client": { 190 | "test-client": [ 191 | { 192 | "name": "client-user-role", 193 | "description": "Client user role" 194 | }, 195 | { 196 | "name": "client-admin-role", 197 | "description": "Client admin role" 198 | } 199 | ] 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /tests/Keycloak.test.ts: -------------------------------------------------------------------------------- 1 | import { Keycloak, IExternalConfig } from "../libs/Keycloak"; 2 | import Axios from "axios"; 3 | 4 | jest.mock("axios"); 5 | jest.mock("../libs/AccessToken"); 6 | jest.mock("../libs/Jwt"); 7 | 8 | describe("Keycloak", () => { 9 | const MockAxios = Axios as jest.Mocked; 10 | 11 | beforeEach(() => { 12 | jest.clearAllMocks(); 13 | MockAxios.create.mockReturnValue({ 14 | get: jest.fn(), 15 | post: jest.fn(), 16 | } as any); 17 | }); 18 | 19 | describe("constructor", () => { 20 | it("should initialize with basic configuration", () => { 21 | const config: IExternalConfig = { 22 | realm: "test-realm", 23 | keycloak_base_url: "https://keycloak.example.org", 24 | client_id: "test-client", 25 | username: "testuser", 26 | password: "testpass", 27 | }; 28 | 29 | const keycloak = new Keycloak(config); 30 | 31 | expect(keycloak.accessToken).toBeDefined(); 32 | expect(keycloak.jwt).toBeDefined(); 33 | expect(MockAxios.create).toHaveBeenCalledWith({ 34 | baseURL: "https://keycloak.example.org", 35 | timeout: 10000, 36 | httpsAgent: undefined, 37 | }); 38 | }); 39 | 40 | it("should use legacy endpoint prefix when is_legacy_endpoint is true", () => { 41 | const config: IExternalConfig = { 42 | realm: "test-realm", 43 | keycloak_base_url: "https://keycloak.example.org", 44 | client_id: "test-client", 45 | is_legacy_endpoint: true, 46 | }; 47 | 48 | new Keycloak(config); 49 | 50 | expect(MockAxios.create).toHaveBeenCalledWith({ 51 | baseURL: "https://keycloak.example.org", 52 | timeout: 10000, 53 | httpsAgent: undefined, 54 | }); 55 | }); 56 | 57 | it("should not use legacy prefix when is_legacy_endpoint is false", () => { 58 | const config: IExternalConfig = { 59 | realm: "test-realm", 60 | keycloak_base_url: "https://keycloak.example.org", 61 | client_id: "test-client", 62 | is_legacy_endpoint: false, 63 | }; 64 | 65 | new Keycloak(config); 66 | 67 | expect(MockAxios.create).toHaveBeenCalledWith({ 68 | baseURL: "https://keycloak.example.org", 69 | timeout: 10000, 70 | httpsAgent: undefined, 71 | }); 72 | }); 73 | 74 | it("should use custom timeout when provided", () => { 75 | const config: IExternalConfig = { 76 | realm: "test-realm", 77 | keycloak_base_url: "https://keycloak.example.org", 78 | client_id: "test-client", 79 | timeout: 5000, 80 | }; 81 | 82 | new Keycloak(config); 83 | 84 | expect(MockAxios.create).toHaveBeenCalledWith({ 85 | baseURL: "https://keycloak.example.org", 86 | timeout: 5000, 87 | httpsAgent: undefined, 88 | }); 89 | }); 90 | 91 | it("should use custom httpsAgent when provided", () => { 92 | const customAgent = { rejectUnauthorized: true }; 93 | const config: IExternalConfig = { 94 | realm: "test-realm", 95 | keycloak_base_url: "https://keycloak.example.org", 96 | client_id: "test-client", 97 | httpsAgent: customAgent, 98 | }; 99 | 100 | new Keycloak(config); 101 | 102 | expect(MockAxios.create).toHaveBeenCalledWith({ 103 | baseURL: "https://keycloak.example.org", 104 | timeout: 10000, 105 | httpsAgent: customAgent, 106 | }); 107 | }); 108 | 109 | it("should support client_secret for client credentials flow", () => { 110 | const config: IExternalConfig = { 111 | realm: "test-realm", 112 | keycloak_base_url: "https://keycloak.example.org", 113 | client_id: "test-client", 114 | client_secret: "super-secret", 115 | }; 116 | 117 | const keycloak = new Keycloak(config); 118 | 119 | expect(keycloak.accessToken).toBeDefined(); 120 | expect(keycloak.jwt).toBeDefined(); 121 | }); 122 | 123 | it("should support onError callback", () => { 124 | const onError = jest.fn(); 125 | const config: IExternalConfig = { 126 | realm: "test-realm", 127 | keycloak_base_url: "https://keycloak.example.org", 128 | client_id: "test-client", 129 | onError, 130 | }; 131 | 132 | const keycloak = new Keycloak(config); 133 | 134 | expect(keycloak.accessToken).toBeDefined(); 135 | }); 136 | 137 | it("should work with all optional parameters", () => { 138 | const onError = jest.fn(); 139 | const httpsAgent = { rejectUnauthorized: true }; 140 | const config: IExternalConfig = { 141 | realm: "test-realm", 142 | keycloak_base_url: "https://keycloak.example.org", 143 | client_id: "test-client", 144 | username: "user", 145 | password: "pass", 146 | client_secret: "secret", 147 | is_legacy_endpoint: true, 148 | timeout: 15000, 149 | httpsAgent, 150 | onError, 151 | }; 152 | 153 | const keycloak = new Keycloak(config); 154 | 155 | expect(keycloak.accessToken).toBeDefined(); 156 | expect(keycloak.jwt).toBeDefined(); 157 | expect(MockAxios.create).toHaveBeenCalledWith({ 158 | baseURL: "https://keycloak.example.org", 159 | timeout: 15000, 160 | httpsAgent, 161 | }); 162 | }); 163 | 164 | it("should have jwt and accessToken as readonly properties in TypeScript", () => { 165 | const config: IExternalConfig = { 166 | realm: "test-realm", 167 | keycloak_base_url: "https://keycloak.example.org", 168 | client_id: "test-client", 169 | }; 170 | 171 | const keycloak = new Keycloak(config); 172 | 173 | // Properties are readonly in TypeScript but not enforced at runtime 174 | expect(keycloak.jwt).toBeDefined(); 175 | expect(keycloak.accessToken).toBeDefined(); 176 | }); 177 | 178 | it("should use timeout of 0 when explicitly set to 0", () => { 179 | const config: IExternalConfig = { 180 | realm: "test-realm", 181 | keycloak_base_url: "https://keycloak.example.org", 182 | client_id: "test-client", 183 | timeout: 0, 184 | }; 185 | 186 | new Keycloak(config); 187 | 188 | expect(MockAxios.create).toHaveBeenCalledWith({ 189 | baseURL: "https://keycloak.example.org", 190 | timeout: 0, 191 | httpsAgent: undefined, 192 | }); 193 | }); 194 | 195 | it("should preserve all config properties in internal config", () => { 196 | const config: IExternalConfig = { 197 | realm: "test-realm", 198 | keycloak_base_url: "https://keycloak.example.org", 199 | client_id: "test-client", 200 | username: "user", 201 | password: "pass", 202 | }; 203 | 204 | // This verifies the config is passed through correctly 205 | new Keycloak(config); 206 | 207 | expect(MockAxios.create).toHaveBeenCalled(); 208 | }); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /libs/AccessToken.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from "querystring"; 2 | import { IInternalConfig } from "./index"; 3 | import { AxiosInstance } from "axios"; 4 | 5 | /** Parameters shared by token request methods. */ 6 | interface ICommonRequestOptions { 7 | grant_type: string; 8 | client_id: string; 9 | client_secret?: string; 10 | } 11 | 12 | /** Options object for token request used by `get()` (and tests). */ 13 | interface IGetOptions extends ICommonRequestOptions { 14 | username?: string; 15 | password?: string; 16 | scope?: string; 17 | } 18 | 19 | /** Options for refresh token request. */ 20 | interface IRefreshOptions extends ICommonRequestOptions { 21 | refresh_token: string; 22 | } 23 | 24 | /** 25 | * AccessToken provides a simple stateful wrapper for a Keycloak 26 | * client credentials or resource owner password credentials grant token, 27 | * with an auto-refresh capability and safety limits to avoid infinite 28 | * recursion during authentication retries. 29 | */ 30 | export class AccessToken { 31 | // Cached token data from Keycloak. Null means 'no token available' 32 | private data: any; 33 | // Maximum number of retries to prevent infinite recursion during refresh 34 | private readonly maxRetries: number = 3; 35 | 36 | constructor(private readonly config: IInternalConfig, private readonly client: AxiosInstance) {} 37 | 38 | // Helper to push errors to the optional `onError` hook provided by 39 | // callers. This centralizes error logging and allows production users 40 | // to capture security-relevant events if desired. 41 | private logError(error: Error, context: string): void { 42 | if (this.config.onError != null) { 43 | this.config.onError(error, context); 44 | } 45 | } 46 | 47 | /** 48 | * Retrieve Keycloak userinfo for the provided access token. 49 | * 50 | * This method performs a GET on the Keycloak `/userinfo` endpoint and 51 | * returns the parsed JSON body. If the call fails (network or HTTP 52 | * error), the underlying Axios error will be propagated. 53 | * 54 | * @param accessToken - The raw access token string 55 | * @returns Resolves with the Keycloak userinfo object as returned by `/userinfo` 56 | * @throws {AxiosError} When the request fails; synchronously re-thrown 57 | * @example 58 | * const info = await accessToken.info('ey...') 59 | */ 60 | async info(accessToken: string): Promise { 61 | const endpoint = `${this.config.prefix}/realms/${this.config.realm}/protocol/openid-connect/userinfo`; 62 | const response = await this.client.get(endpoint, { 63 | headers: { 64 | Authorization: "Bearer " + accessToken, 65 | }, 66 | }); 67 | 68 | return response.data; 69 | } 70 | 71 | /** 72 | * Exchange a refresh token for a new access token / refresh token pair. 73 | * 74 | * This method uses Keycloak's `refresh_token` grant type. On success 75 | * the full Axios response is returned and the caller can read 76 | * `response.data` for the `access_token` value. 77 | * 78 | * @param refreshToken - Refresh token string from a previously issued token pair 79 | * @returns Resolves with the Axios response containing `data` with the refreshed token pair 80 | * @throws {AxiosError} When the request fails (e.g., refresh token expired) 81 | * @example 82 | * const resp = await accessToken.refresh('refresh-token') 83 | * // resp.data.access_token -> 'new-token' 84 | */ 85 | async refresh(refreshToken: string): Promise { 86 | const options: IRefreshOptions = { 87 | grant_type: "refresh_token", 88 | client_id: this.config.client_id, 89 | refresh_token: refreshToken, 90 | }; 91 | if (this.config.client_secret != null) { 92 | options.client_secret = this.config.client_secret; 93 | } 94 | 95 | const endpoint = `${this.config.prefix}/realms/${this.config.realm}/protocol/openid-connect/token`; 96 | return await this.client.post(endpoint, stringify({ ...options })); 97 | } 98 | 99 | /** 100 | * Returns a valid access token; if a token is cached it'll be validated 101 | * via `userinfo`, otherwise a new token request is performed. 102 | * 103 | * Logic summary: 104 | * - If `this.data` is null: perform a token request. The default 105 | * grant is `client_credentials`; when `username` and `password` are 106 | * configured the `password` grant is used instead. 107 | * - If the token is present: validate it by calling Keycloak's 108 | * `/userinfo`. If validation fails and a `refresh_token` exists, 109 | * attempt to refresh. If refresh fails or no refresh token exists, 110 | * re-authenticate (with capped retries). 111 | * 112 | * @param scope - Optional OIDC scope to request (defaults to `openid`) 113 | * @param depth - Internal retry depth (used to prevent recursion) 114 | * @returns A Promise resolving to the access token string 115 | * @throws {Error} `Max authentication retry depth exceeded` when too many retries 116 | * @throws {AxiosError} When token requests, `userinfo` calls or refresh fail 117 | * @example 118 | * const token = await accessToken.get() // default `openid` scope 119 | * const userToken = await accessToken.get('openid profile') 120 | */ 121 | async get(scope?: string, depth: number = 0): Promise { 122 | // Prevent infinite recursion when repeated attempts fail; report to 123 | // monitoring hook and raise an error to the caller. 124 | if (depth >= this.maxRetries) { 125 | const error = new Error("Max authentication retry depth exceeded"); 126 | this.logError(error, "AccessToken.get"); 127 | throw error; 128 | } 129 | 130 | // No cached token; request a new one. By default use the 131 | // `client_credentials` grant - it is preferred for service-to-service 132 | // flows. If the runtime configuration contains `username`/`password`, 133 | // switch to `password` grant to support resource-owner flows. 134 | if (this.data == null) { 135 | const options: IGetOptions = { 136 | grant_type: "client_credentials", 137 | client_id: this.config.client_id, 138 | }; 139 | 140 | if (this.config.username != null && this.config.password != null) { 141 | options.grant_type = "password"; 142 | options.username = this.config.username; 143 | options.password = this.config.password; 144 | } 145 | 146 | // Attach a client secret when available; certain Keycloak clients 147 | // require it for the client credentials flow. 148 | if (this.config.client_secret != null) { 149 | options.client_secret = this.config.client_secret; 150 | } 151 | 152 | // Default scope to `openid` for Keycloak's userinfo endpoint and 153 | // token policies; allow overriding by callers. 154 | options.scope = scope ?? "openid"; 155 | 156 | const endpoint = `${this.config.prefix}/realms/${this.config.realm}/protocol/openid-connect/token`; 157 | try { 158 | const response = await this.client.post(endpoint, stringify({ ...options })); 159 | this.data = response.data; 160 | 161 | return this.data.access_token; 162 | } catch (err) { 163 | this.logError(err instanceof Error ? err : new Error(String(err)), "AccessToken.get:request"); 164 | throw err; 165 | } 166 | } else { 167 | try { 168 | // Validate the token via `userinfo` endpoint. If validation 169 | // succeeds the token is still valid; otherwise a refresh may be 170 | // attempted or a full re-authentication will be performed. 171 | await this.info(this.data.access_token); 172 | 173 | return this.data.access_token; 174 | } catch (err) { 175 | this.logError(err instanceof Error ? err : new Error(String(err)), "AccessToken.get:info"); 176 | try { 177 | // Try to refresh using a refresh token if we have one. 178 | if (this.data.refresh_token == null) { 179 | throw new Error("No refresh token available"); 180 | } 181 | const response = await this.refresh(this.data.refresh_token); 182 | this.data = response.data; 183 | 184 | return this.data.access_token; 185 | } catch (err) { 186 | // If refresh fails, clear cached data and attempt a full 187 | // re-authentication (with a capped retry depth). 188 | this.logError(err instanceof Error ? err : new Error(String(err)), "AccessToken.get:refresh"); 189 | delete this.data; 190 | 191 | return await this.get(scope, depth + 1); 192 | } 193 | } 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "CommonJS", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "resolveJsonModule": true, /* Enable importing .json files. */ 39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 45 | 46 | /* Emit */ 47 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 52 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 77 | 78 | /* Type Checking */ 79 | "strict": true, /* Enable all strict type-checking options. */ 80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 81 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | }, 103 | "include": ["./libs/*"] 104 | } 105 | -------------------------------------------------------------------------------- /tests/integration/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { Keycloak } from "../../libs/Keycloak"; 2 | import { IExternalConfig } from "../../libs/Keycloak"; 3 | // Integration test configuration 4 | const config: IExternalConfig = { 5 | realm: "test-realm", 6 | keycloak_base_url: process.env.KEYCLOAK_URL || "http://localhost:8080", 7 | client_id: "test-client", 8 | client_secret: "test-secret", 9 | username: "testuser", 10 | password: "testpass", 11 | }; 12 | 13 | describe("Keycloak Integration Tests", () => { 14 | let keycloak: Keycloak; 15 | 16 | beforeAll(() => { 17 | // Initialize Keycloak instance for all tests 18 | keycloak = new Keycloak(config); 19 | }); 20 | 21 | describe("AccessToken - Client Credentials Grant", () => { 22 | it("should generate access token using client credentials", async () => { 23 | const clientKeycloak = new Keycloak({ 24 | ...config, 25 | username: undefined, 26 | password: undefined, 27 | }); 28 | 29 | const accessToken = await clientKeycloak.accessToken.get(); 30 | const token = clientKeycloak.jwt.decode(accessToken); 31 | 32 | expect(accessToken).toBeDefined(); 33 | expect(typeof accessToken).toBe("string"); 34 | expect(token.content).toBeDefined(); 35 | expect(token.content.iss).toContain("test-realm"); 36 | expect(token.content.azp).toBe("test-client"); 37 | expect(token.isExpired()).toBe(false); 38 | }, 30000); 39 | 40 | it("should cache and reuse valid token", async () => { 41 | const clientKeycloak = new Keycloak({ 42 | ...config, 43 | username: undefined, 44 | password: undefined, 45 | }); 46 | 47 | const token1 = await clientKeycloak.accessToken.get(); 48 | const token2 = await clientKeycloak.accessToken.get(); 49 | 50 | expect(token1).toBe(token2); 51 | }, 30000); 52 | }); 53 | 54 | describe("AccessToken - Password Grant", () => { 55 | it("should generate access token using password grant", async () => { 56 | const accessToken = await keycloak.accessToken.get(); 57 | const token = keycloak.jwt.decode(accessToken); 58 | 59 | expect(accessToken).toBeDefined(); 60 | expect(typeof accessToken).toBe("string"); 61 | expect(token.content).toBeDefined(); 62 | expect(token.content.iss).toContain("test-realm"); 63 | expect(token.content.preferred_username).toBe("testuser"); 64 | expect(token.isExpired()).toBe(false); 65 | }, 30000); 66 | 67 | it("should retrieve user information", async () => { 68 | const accessToken = await keycloak.accessToken.get(); 69 | const userInfo = await keycloak.accessToken.info(accessToken); 70 | 71 | expect(userInfo).toBeDefined(); 72 | expect(userInfo.preferred_username).toBe("testuser"); 73 | expect(userInfo.email).toBe("testuser@example.com"); 74 | expect(userInfo.given_name).toBe("Test"); 75 | expect(userInfo.family_name).toBe("User"); 76 | }, 30000); 77 | 78 | it("should handle token refresh", async () => { 79 | // Get initial token 80 | const accessToken1 = await keycloak.accessToken.get(); 81 | const token1 = keycloak.jwt.decode(accessToken1); 82 | 83 | // Force token to be considered expired by manipulating the internal state 84 | // In real scenario, we would wait for expiration, but for testing we'll get a new instance 85 | const newKeycloak = new Keycloak(config); 86 | const accessToken2 = await newKeycloak.accessToken.get(); 87 | const token2 = newKeycloak.jwt.decode(accessToken2); 88 | 89 | expect(accessToken1).toBeDefined(); 90 | expect(accessToken2).toBeDefined(); 91 | expect(token1.content.sub).toBe(token2.content.sub); // Same user 92 | }, 30000); 93 | }); 94 | 95 | describe("Token Role Verification", () => { 96 | it("should verify realm roles", async () => { 97 | const accessToken = await keycloak.accessToken.get(); 98 | const token = keycloak.jwt.decode(accessToken); 99 | 100 | expect(token.hasRealmRole("user")).toBe(true); 101 | expect(token.hasRealmRole("admin")).toBe(false); 102 | expect(token.hasRealmRole("nonexistent")).toBe(false); 103 | }, 30000); 104 | 105 | it("should verify application roles", async () => { 106 | const accessToken = await keycloak.accessToken.get(); 107 | const token = keycloak.jwt.decode(accessToken); 108 | 109 | expect(token.hasApplicationRole("test-client", "client-user-role")).toBe(true); 110 | expect(token.hasApplicationRole("test-client", "client-admin-role")).toBe(false); 111 | expect(token.hasApplicationRole("test-client", "nonexistent")).toBe(false); 112 | expect(token.hasApplicationRole("other-client", "some-role")).toBe(false); 113 | }, 30000); 114 | 115 | it("should verify admin user roles", async () => { 116 | const adminKeycloak = new Keycloak({ 117 | ...config, 118 | username: "admin", 119 | password: "adminpass", 120 | }); 121 | 122 | const accessToken = await adminKeycloak.accessToken.get(); 123 | const token = adminKeycloak.jwt.decode(accessToken); 124 | 125 | expect(token.hasRealmRole("admin")).toBe(true); 126 | expect(token.hasRealmRole("user")).toBe(true); 127 | expect(token.hasApplicationRole("test-client", "client-admin-role")).toBe(true); 128 | expect(token.hasApplicationRole("test-client", "client-user-role")).toBe(true); 129 | }, 30000); 130 | }); 131 | 132 | describe("JWT Online Verification", () => { 133 | it("should verify valid token online", async () => { 134 | const accessToken = await keycloak.accessToken.get(); 135 | 136 | const verified = await keycloak.jwt.verify(accessToken); 137 | 138 | expect(verified).toBeDefined(); 139 | expect(verified.content.preferred_username).toBe("testuser"); 140 | }, 30000); 141 | 142 | it("should decode token without verification", async () => { 143 | const accessToken = await keycloak.accessToken.get(); 144 | 145 | const decoded = keycloak.jwt.decode(accessToken); 146 | 147 | expect(decoded).toBeDefined(); 148 | expect(decoded.content.preferred_username).toBe("testuser"); 149 | }, 30000); 150 | 151 | it("should reject invalid token", async () => { 152 | const invalidToken = 153 | "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.invalid"; 154 | 155 | await expect(keycloak.jwt.verify(invalidToken)).rejects.toThrow(); 156 | }, 30000); 157 | }); 158 | 159 | describe("JWT Offline Verification", () => { 160 | let publicKey: string; 161 | 162 | beforeAll(async () => { 163 | // Fetch public key from Keycloak 164 | const axios = require("axios"); 165 | const realmUrl = `${config.keycloak_base_url}/realms/${config.realm}`; 166 | const response = await axios.get(realmUrl); 167 | publicKey = response.data.public_key; 168 | }); 169 | 170 | it("should verify token offline with public key", async () => { 171 | const accessToken = await keycloak.accessToken.get(); 172 | 173 | // Format public key as PEM 174 | const publicKeyPem = `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`; 175 | const verified = await keycloak.jwt.verifyOffline(accessToken, publicKeyPem); 176 | 177 | expect(verified).toBeDefined(); 178 | expect(verified.content.preferred_username).toBe("testuser"); 179 | }, 30000); 180 | 181 | it("should reject token with wrong public key", async () => { 182 | const accessToken = await keycloak.accessToken.get(); 183 | const wrongKey = `-----BEGIN PUBLIC KEY----- 184 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwrong 185 | -----END PUBLIC KEY-----`; 186 | 187 | await expect(keycloak.jwt.verifyOffline(accessToken, wrongKey)).rejects.toThrow(); 188 | }, 30000); 189 | 190 | it("should verify token expiry offline", async () => { 191 | const accessToken = await keycloak.accessToken.get(); 192 | 193 | const publicKeyPem = `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`; 194 | const verified = await keycloak.jwt.verifyOffline(accessToken, publicKeyPem); 195 | 196 | expect(verified.isExpired()).toBe(false); 197 | expect(verified.content.exp).toBeGreaterThan(Date.now() / 1000); 198 | }, 30000); 199 | }); 200 | 201 | describe("Token Expiry and Refresh", () => { 202 | it("should detect token expiry", async () => { 203 | const accessToken = await keycloak.accessToken.get(); 204 | const token = keycloak.jwt.decode(accessToken); 205 | 206 | expect(token.isExpired()).toBe(false); 207 | 208 | // Verify the expiration time is in the future 209 | const currentTime = Math.floor(Date.now() / 1000); 210 | expect(token.content.exp).toBeGreaterThan(currentTime); 211 | }, 30000); 212 | }); 213 | 214 | describe("Error Handling", () => { 215 | it("should handle invalid credentials", async () => { 216 | const invalidKeycloak = new Keycloak({ 217 | ...config, 218 | username: "invalid", 219 | password: "invalid", 220 | }); 221 | 222 | await expect(invalidKeycloak.accessToken.get()).rejects.toThrow(); 223 | }, 30000); 224 | 225 | it("should handle invalid client secret", async () => { 226 | const invalidKeycloak = new Keycloak({ 227 | ...config, 228 | client_secret: "invalid-secret", 229 | username: undefined, 230 | password: undefined, 231 | }); 232 | 233 | await expect(invalidKeycloak.accessToken.get()).rejects.toThrow(); 234 | }, 30000); 235 | 236 | it("should handle invalid realm", async () => { 237 | const invalidKeycloak = new Keycloak({ 238 | ...config, 239 | realm: "nonexistent-realm", 240 | }); 241 | 242 | await expect(invalidKeycloak.accessToken.get()).rejects.toThrow(); 243 | }, 30000); 244 | }); 245 | 246 | describe("Security Features", () => { 247 | it("should respect timeout configuration", async () => { 248 | const timeoutKeycloak = new Keycloak({ 249 | ...config, 250 | timeout: 1, // 1ms timeout 251 | }); 252 | 253 | // This should timeout 254 | await expect(timeoutKeycloak.accessToken.get()).rejects.toThrow(); 255 | }, 30000); 256 | 257 | it("should handle custom error callback", async () => { 258 | const errors: Array<{ error: any; context: string }> = []; 259 | 260 | const errorKeycloak = new Keycloak({ 261 | ...config, 262 | username: "invalid", 263 | password: "invalid", 264 | onError: (error, context) => { 265 | errors.push({ error, context }); 266 | }, 267 | }); 268 | 269 | await expect(errorKeycloak.accessToken.get()).rejects.toThrow(); 270 | 271 | expect(errors.length).toBeGreaterThan(0); 272 | }, 30000); 273 | }); 274 | 275 | describe("Multiple Scopes", () => { 276 | it("should request token with custom scope", async () => { 277 | const accessToken = await keycloak.accessToken.get("openid profile email"); 278 | const token = keycloak.jwt.decode(accessToken); 279 | 280 | expect(accessToken).toBeDefined(); 281 | expect(token.content.preferred_username).toBe("testuser"); 282 | }, 30000); 283 | 284 | it("should request token with offline_access scope", async () => { 285 | const accessToken = await keycloak.accessToken.get("openid offline_access"); 286 | const token = keycloak.jwt.decode(accessToken); 287 | 288 | expect(accessToken).toBeDefined(); 289 | expect(token.content.preferred_username).toBe("testuser"); 290 | }, 30000); 291 | }); 292 | }); 293 | -------------------------------------------------------------------------------- /tests/AccessToken.test.ts: -------------------------------------------------------------------------------- 1 | import { AccessToken } from "../libs/AccessToken"; 2 | import { AxiosInstance } from "axios"; 3 | import { stringify } from "querystring"; 4 | 5 | describe("AccessToken", () => { 6 | let mockClient: jest.Mocked; 7 | let accessToken: AccessToken; 8 | let mockConfig: any; 9 | let onErrorSpy: jest.Mock; 10 | 11 | beforeEach(() => { 12 | jest.clearAllMocks(); 13 | onErrorSpy = jest.fn(); 14 | 15 | mockConfig = { 16 | realm: "test-realm", 17 | keycloak_base_url: "https://keycloak.example.org", 18 | client_id: "test-client", 19 | username: "testuser", 20 | password: "testpass", 21 | prefix: "", 22 | onError: onErrorSpy, 23 | }; 24 | 25 | mockClient = { 26 | get: jest.fn(), 27 | post: jest.fn(), 28 | } as any; 29 | 30 | accessToken = new AccessToken(mockConfig, mockClient); 31 | }); 32 | 33 | describe("info", () => { 34 | it("should retrieve user info with access token", async () => { 35 | const token = "valid.access.token"; 36 | const mockUserInfo = { sub: "user-123", email: "user@example.org" }; 37 | 38 | mockClient.get.mockResolvedValue({ data: mockUserInfo }); 39 | 40 | const result = await accessToken.info(token); 41 | 42 | expect(mockClient.get).toHaveBeenCalledWith("/realms/test-realm/protocol/openid-connect/userinfo", { 43 | headers: { 44 | Authorization: "Bearer valid.access.token", 45 | }, 46 | }); 47 | expect(result).toEqual(mockUserInfo); 48 | }); 49 | 50 | it("should use prefix for legacy endpoints", async () => { 51 | const legacyConfig = { ...mockConfig, prefix: "/auth" }; 52 | const legacyAccessToken = new AccessToken(legacyConfig, mockClient); 53 | const token = "valid.access.token"; 54 | 55 | mockClient.get.mockResolvedValue({ data: {} }); 56 | 57 | await legacyAccessToken.info(token); 58 | 59 | expect(mockClient.get).toHaveBeenCalledWith( 60 | "/auth/realms/test-realm/protocol/openid-connect/userinfo", 61 | expect.any(Object) 62 | ); 63 | }); 64 | 65 | it("should throw error on failed request", async () => { 66 | const token = "invalid.token"; 67 | mockClient.get.mockRejectedValue(new Error("Unauthorized")); 68 | 69 | await expect(accessToken.info(token)).rejects.toThrow("Unauthorized"); 70 | }); 71 | 72 | it("should work with config that has client_secret", async () => { 73 | const configWithSecret = { ...mockConfig, client_secret: "secret123" }; 74 | const atWithSecret = new AccessToken(configWithSecret, mockClient); 75 | const token = "valid.access.token"; 76 | const mockUserInfo = { sub: "user-123" }; 77 | 78 | mockClient.get.mockResolvedValue({ data: mockUserInfo }); 79 | 80 | const result = await atWithSecret.info(token); 81 | 82 | expect(result).toEqual(mockUserInfo); 83 | }); 84 | }); 85 | 86 | describe("refresh", () => { 87 | it("should refresh token using refresh_token", async () => { 88 | const refreshToken = "valid.refresh.token"; 89 | const mockResponse = { 90 | data: { 91 | access_token: "new.access.token", 92 | refresh_token: "new.refresh.token", 93 | }, 94 | }; 95 | 96 | mockClient.post.mockResolvedValue(mockResponse); 97 | 98 | const result = await accessToken.refresh(refreshToken); 99 | 100 | expect(mockClient.post).toHaveBeenCalledWith( 101 | "/realms/test-realm/protocol/openid-connect/token", 102 | stringify({ 103 | grant_type: "refresh_token", 104 | client_id: "test-client", 105 | refresh_token: refreshToken, 106 | }) 107 | ); 108 | expect(result).toEqual(mockResponse); 109 | }); 110 | 111 | it("should include client_secret when provided", async () => { 112 | const configWithSecret = { ...mockConfig, client_secret: "super-secret" }; 113 | const accessTokenWithSecret = new AccessToken(configWithSecret, mockClient); 114 | const refreshToken = "valid.refresh.token"; 115 | 116 | mockClient.post.mockResolvedValue({ data: {} }); 117 | 118 | await accessTokenWithSecret.refresh(refreshToken); 119 | 120 | expect(mockClient.post).toHaveBeenCalledWith( 121 | expect.any(String), 122 | stringify({ 123 | grant_type: "refresh_token", 124 | client_id: "test-client", 125 | refresh_token: refreshToken, 126 | client_secret: "super-secret", 127 | }) 128 | ); 129 | }); 130 | 131 | it("should throw error on failed refresh", async () => { 132 | const refreshToken = "invalid.refresh.token"; 133 | mockClient.post.mockRejectedValue(new Error("Invalid refresh token")); 134 | 135 | await expect(accessToken.refresh(refreshToken)).rejects.toThrow("Invalid refresh token"); 136 | }); 137 | }); 138 | 139 | describe("get", () => { 140 | it("should get new access token when data is null", async () => { 141 | const mockResponse = { 142 | data: { 143 | access_token: "new.access.token", 144 | refresh_token: "new.refresh.token", 145 | }, 146 | }; 147 | 148 | mockClient.post.mockResolvedValue(mockResponse); 149 | 150 | const result = await accessToken.get(); 151 | 152 | expect(mockClient.post).toHaveBeenCalledWith( 153 | "/realms/test-realm/protocol/openid-connect/token", 154 | stringify({ 155 | grant_type: "password", 156 | client_id: "test-client", 157 | username: "testuser", 158 | password: "testpass", 159 | scope: "openid", 160 | }) 161 | ); 162 | expect(result).toBe("new.access.token"); 163 | }); 164 | 165 | it("should include scope when provided", async () => { 166 | const mockResponse = { 167 | data: { 168 | access_token: "new.access.token", 169 | refresh_token: "new.refresh.token", 170 | }, 171 | }; 172 | 173 | mockClient.post.mockResolvedValue(mockResponse); 174 | 175 | await accessToken.get("openid profile"); 176 | 177 | expect(mockClient.post).toHaveBeenCalledWith( 178 | expect.any(String), 179 | stringify({ 180 | grant_type: "password", 181 | client_id: "test-client", 182 | username: "testuser", 183 | password: "testpass", 184 | scope: "openid profile", 185 | }) 186 | ); 187 | }); 188 | 189 | it("should use client_credentials grant when no username/password provided", async () => { 190 | const configNoAuth = { ...mockConfig }; 191 | delete configNoAuth.username; 192 | delete configNoAuth.password; 193 | configNoAuth.client_secret = "super-secret"; 194 | const accessTokenNoAuth = new AccessToken(configNoAuth, mockClient); 195 | 196 | mockClient.post.mockResolvedValue({ 197 | data: { 198 | access_token: "new.access.token", 199 | refresh_token: "new.refresh.token", 200 | }, 201 | }); 202 | 203 | await accessTokenNoAuth.get(); 204 | 205 | expect(mockClient.post).toHaveBeenCalledWith( 206 | expect.any(String), 207 | stringify({ 208 | grant_type: "client_credentials", 209 | client_id: "test-client", 210 | client_secret: "super-secret", 211 | scope: "openid", 212 | }) 213 | ); 214 | }); 215 | 216 | it("should return cached token when still valid", async () => { 217 | const mockResponse = { 218 | data: { 219 | access_token: "cached.access.token", 220 | refresh_token: "cached.refresh.token", 221 | }, 222 | }; 223 | 224 | mockClient.post.mockResolvedValue(mockResponse); 225 | mockClient.get.mockResolvedValue({ data: { sub: "user-123" } }); 226 | 227 | // First call to populate cache 228 | const firstToken = await accessToken.get(); 229 | expect(firstToken).toBe("cached.access.token"); 230 | 231 | // Second call should use cache 232 | const secondToken = await accessToken.get(); 233 | expect(secondToken).toBe("cached.access.token"); 234 | expect(mockClient.post).toHaveBeenCalledTimes(1); 235 | expect(mockClient.get).toHaveBeenCalledTimes(1); 236 | }); 237 | 238 | it("should refresh token when cached token is invalid", async () => { 239 | const initialResponse = { 240 | data: { 241 | access_token: "initial.access.token", 242 | refresh_token: "initial.refresh.token", 243 | }, 244 | }; 245 | 246 | const refreshResponse = { 247 | data: { 248 | access_token: "refreshed.access.token", 249 | refresh_token: "refreshed.refresh.token", 250 | }, 251 | }; 252 | 253 | mockClient.post.mockResolvedValueOnce(initialResponse).mockResolvedValueOnce(refreshResponse); 254 | 255 | // First call to populate cache 256 | await accessToken.get(); 257 | 258 | // Mock info call failing (token invalid) 259 | mockClient.get.mockRejectedValueOnce(new Error("Token expired")); 260 | 261 | // Second call should refresh token 262 | const result = await accessToken.get(); 263 | 264 | expect(result).toBe("refreshed.access.token"); 265 | expect(mockClient.post).toHaveBeenCalledTimes(2); 266 | expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:info"); 267 | }); 268 | 269 | it("should re-authenticate when refresh fails", async () => { 270 | const initialResponse = { 271 | data: { 272 | access_token: "initial.access.token", 273 | refresh_token: "initial.refresh.token", 274 | }, 275 | }; 276 | 277 | const newAuthResponse = { 278 | data: { 279 | access_token: "new.auth.token", 280 | refresh_token: "new.auth.refresh.token", 281 | }, 282 | }; 283 | 284 | mockClient.post 285 | .mockResolvedValueOnce(initialResponse) 286 | .mockRejectedValueOnce(new Error("Refresh token expired")) 287 | .mockResolvedValueOnce(newAuthResponse); 288 | 289 | // First call to populate cache 290 | await accessToken.get(); 291 | 292 | // Mock info and refresh failing 293 | mockClient.get.mockRejectedValueOnce(new Error("Token expired")); 294 | 295 | // Should re-authenticate 296 | const result = await accessToken.get(); 297 | 298 | expect(result).toBe("new.auth.token"); 299 | expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:info"); 300 | expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:refresh"); 301 | }); 302 | 303 | it("should re-authenticate when no refresh token available", async () => { 304 | const initialResponse = { 305 | data: { 306 | access_token: "initial.access.token", 307 | refresh_token: null, 308 | }, 309 | }; 310 | 311 | const newAuthResponse = { 312 | data: { 313 | access_token: "new.auth.token", 314 | refresh_token: "new.auth.refresh.token", 315 | }, 316 | }; 317 | 318 | mockClient.post 319 | .mockResolvedValueOnce(initialResponse) 320 | // Reauth 321 | .mockResolvedValueOnce(newAuthResponse); 322 | 323 | // First call to populate cache 324 | await accessToken.get(); 325 | 326 | // Mock info failing so refresh path is taken 327 | mockClient.get.mockRejectedValueOnce(new Error("Token expired")); 328 | 329 | const result = await accessToken.get(); 330 | 331 | expect(result).toBe("new.auth.token"); 332 | expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:info"); 333 | expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:refresh"); 334 | }); 335 | 336 | it("should throw error when depth parameter equals maxRetries", async () => { 337 | // Call with depth=3 (equals maxRetries) 338 | await expect(accessToken.get(undefined, 3)).rejects.toThrow("Max authentication retry depth exceeded"); 339 | expect(onErrorSpy).toHaveBeenCalledWith( 340 | expect.objectContaining({ message: "Max authentication retry depth exceeded" }), 341 | "AccessToken.get" 342 | ); 343 | }); 344 | 345 | it("should throw error and log when token request fails", async () => { 346 | mockClient.post.mockRejectedValue(new Error("Network error")); 347 | await expect(accessToken.get()).rejects.toThrow("Network error"); 348 | expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:request"); 349 | }); 350 | 351 | it("should call onError callback on errors", async () => { 352 | const initialResponse = { 353 | data: { 354 | access_token: "initial.access.token", 355 | refresh_token: "initial.refresh.token", 356 | }, 357 | }; 358 | 359 | mockClient.post.mockResolvedValueOnce(initialResponse); 360 | 361 | // First call 362 | await accessToken.get(); 363 | 364 | // Mock failures 365 | const infoError = new Error("Info failed"); 366 | mockClient.get.mockRejectedValueOnce(infoError); 367 | mockClient.post.mockRejectedValueOnce(new Error("Refresh failed")); 368 | mockClient.post.mockResolvedValueOnce({ 369 | data: { 370 | access_token: "new.token", 371 | refresh_token: "new.refresh", 372 | }, 373 | }); 374 | 375 | await accessToken.get(); 376 | 377 | expect(onErrorSpy).toHaveBeenCalled(); 378 | expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:info"); 379 | expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:refresh"); 380 | }); 381 | 382 | it("should not call onError when not configured", async () => { 383 | const configNoError = { ...mockConfig }; 384 | delete configNoError.onError; 385 | const accessTokenNoError = new AccessToken(configNoError, mockClient); 386 | 387 | const response = { 388 | data: { 389 | access_token: "token", 390 | refresh_token: "refresh", 391 | }, 392 | }; 393 | 394 | mockClient.post.mockResolvedValueOnce(response); 395 | mockClient.get.mockRejectedValueOnce(new Error("Failed")); 396 | mockClient.post.mockRejectedValueOnce(new Error("Refresh failed")); 397 | mockClient.post.mockResolvedValueOnce(response); 398 | 399 | await accessTokenNoError.get(); 400 | 401 | // Should not throw even without onError callback 402 | expect(mockClient.post).toHaveBeenCalled(); 403 | }); 404 | 405 | it("should handle non-Error objects in catch blocks", async () => { 406 | const response = { 407 | data: { 408 | access_token: "token", 409 | refresh_token: "refresh", 410 | }, 411 | }; 412 | 413 | mockClient.post.mockResolvedValueOnce(response); 414 | 415 | await accessToken.get(); 416 | 417 | mockClient.get.mockRejectedValueOnce("string error"); 418 | mockClient.post.mockRejectedValueOnce({ code: "ERROR" }); 419 | mockClient.post.mockResolvedValueOnce(response); 420 | 421 | const result = await accessToken.get(); 422 | 423 | expect(result).toBe("token"); 424 | expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:info"); 425 | expect(onErrorSpy).toHaveBeenCalledWith(expect.any(Error), "AccessToken.get:refresh"); 426 | }); 427 | 428 | it("should preserve scope parameter during retry", async () => { 429 | const response = { 430 | data: { 431 | access_token: "token", 432 | refresh_token: "refresh", 433 | }, 434 | }; 435 | 436 | mockClient.post.mockResolvedValueOnce(response); 437 | mockClient.get.mockRejectedValueOnce(new Error("Failed")); 438 | mockClient.post.mockRejectedValueOnce(new Error("Refresh failed")); 439 | mockClient.post.mockResolvedValueOnce(response); 440 | 441 | await accessToken.get("openid profile"); 442 | 443 | // Check that last call includes the scope (querystring uses %20 for spaces) 444 | const lastCall = mockClient.post.mock.calls[mockClient.post.mock.calls.length - 1]; 445 | expect(lastCall[1]).toContain("scope=openid%20profile"); 446 | }); 447 | }); 448 | }); 449 | -------------------------------------------------------------------------------- /tests/Token.test.ts: -------------------------------------------------------------------------------- 1 | import { Token } from "../libs/Token"; 2 | import { decode } from "jsonwebtoken"; 3 | 4 | jest.mock("jsonwebtoken"); 5 | 6 | describe("Token", () => { 7 | const mockDecode = decode as jest.MockedFunction; 8 | 9 | beforeEach(() => { 10 | jest.clearAllMocks(); 11 | }); 12 | 13 | describe("constructor", () => { 14 | it("should create a token with valid JWT payload", () => { 15 | const mockPayload = { 16 | iss: "https://keycloak.example.org", 17 | sub: "user-123", 18 | aud: "client-app", 19 | exp: Math.floor(Date.now() / 1000) + 3600, 20 | iat: Math.floor(Date.now() / 1000), 21 | email: "user@example.org", 22 | preferred_username: "testuser", 23 | realm_access: { roles: ["user", "admin"] }, 24 | resource_access: { "my-app": { roles: ["app-role"] } }, 25 | }; 26 | 27 | mockDecode.mockReturnValue(mockPayload); 28 | 29 | const token = new Token("mock.jwt.token"); 30 | 31 | expect(token.token).toBe("mock.jwt.token"); 32 | expect(token.content).toMatchObject(mockPayload); 33 | expect(mockDecode).toHaveBeenCalledWith("mock.jwt.token", { json: true }); 34 | }); 35 | 36 | it("should accept token with azp instead of aud", () => { 37 | const mockPayload = { 38 | iss: "https://keycloak.example.org", 39 | sub: "user-123", 40 | azp: "client-app", 41 | exp: Math.floor(Date.now() / 1000) + 3600, 42 | iat: Math.floor(Date.now() / 1000), 43 | }; 44 | 45 | mockDecode.mockReturnValue(mockPayload); 46 | 47 | const token = new Token("mock.jwt.token"); 48 | 49 | expect(token.content.aud).toBe("client-app"); 50 | }); 51 | 52 | it("should throw error for token missing required iss field", () => { 53 | mockDecode.mockReturnValue({ 54 | sub: "user-123", 55 | aud: "client-app", 56 | exp: 1234567890, 57 | iat: 1234567890, 58 | }); 59 | 60 | expect(() => new Token("invalid.token")).toThrow("Invalid token"); 61 | }); 62 | 63 | it("should throw error for token missing required sub field", () => { 64 | mockDecode.mockReturnValue({ 65 | iss: "https://keycloak.example.org", 66 | aud: "client-app", 67 | exp: 1234567890, 68 | iat: 1234567890, 69 | }); 70 | 71 | expect(() => new Token("invalid.token")).toThrow("Invalid token"); 72 | }); 73 | 74 | it("should throw error for token missing required aud field", () => { 75 | mockDecode.mockReturnValue({ 76 | iss: "https://keycloak.example.org", 77 | sub: "user-123", 78 | exp: 1234567890, 79 | iat: 1234567890, 80 | }); 81 | 82 | expect(() => new Token("invalid.token")).toThrow("Invalid token"); 83 | }); 84 | 85 | it("should throw error for token missing required exp field", () => { 86 | mockDecode.mockReturnValue({ 87 | iss: "https://keycloak.example.org", 88 | sub: "user-123", 89 | aud: "client-app", 90 | iat: 1234567890, 91 | }); 92 | 93 | expect(() => new Token("invalid.token")).toThrow("Invalid token"); 94 | }); 95 | 96 | it("should throw error for token missing required iat field", () => { 97 | mockDecode.mockReturnValue({ 98 | iss: "https://keycloak.example.org", 99 | sub: "user-123", 100 | aud: "client-app", 101 | exp: 1234567890, 102 | }); 103 | 104 | expect(() => new Token("invalid.token")).toThrow("Invalid token"); 105 | }); 106 | 107 | it("should throw error when decode returns null", () => { 108 | mockDecode.mockReturnValue(null); 109 | 110 | expect(() => new Token("invalid.token")).toThrow("Invalid token"); 111 | }); 112 | 113 | it("should throw error when payload is undefined", () => { 114 | mockDecode.mockReturnValue(undefined as any); 115 | 116 | expect(() => new Token("invalid.token")).toThrow("Invalid token"); 117 | }); 118 | 119 | it("should throw error when all fields except iss are missing", () => { 120 | mockDecode.mockReturnValue({ 121 | iss: "https://keycloak.example.org", 122 | }); 123 | 124 | expect(() => new Token("invalid.token")).toThrow("Invalid token"); 125 | }); 126 | }); 127 | 128 | describe("isExpired", () => { 129 | it("should return false for non-expired token", () => { 130 | const futureExp = Math.floor(Date.now() / 1000) + 3600; 131 | mockDecode.mockReturnValue({ 132 | iss: "https://keycloak.example.org", 133 | sub: "user-123", 134 | aud: "client-app", 135 | exp: futureExp, 136 | iat: Math.floor(Date.now() / 1000), 137 | }); 138 | 139 | const token = new Token("valid.token"); 140 | 141 | expect(token.isExpired()).toBe(false); 142 | }); 143 | 144 | it("should return true for expired token", () => { 145 | const pastExp = Math.floor(Date.now() / 1000) - 3600; 146 | mockDecode.mockReturnValue({ 147 | iss: "https://keycloak.example.org", 148 | sub: "user-123", 149 | aud: "client-app", 150 | exp: pastExp, 151 | iat: Math.floor(Date.now() / 1000) - 7200, 152 | }); 153 | 154 | const token = new Token("expired.token"); 155 | 156 | expect(token.isExpired()).toBe(true); 157 | }); 158 | 159 | it("should return true for token expiring at current time", () => { 160 | const nowExp = Math.floor(Date.now() / 1000); 161 | mockDecode.mockReturnValue({ 162 | iss: "https://keycloak.example.org", 163 | sub: "user-123", 164 | aud: "client-app", 165 | exp: nowExp, 166 | iat: nowExp - 3600, 167 | }); 168 | 169 | const token = new Token("expiring.token"); 170 | 171 | expect(token.isExpired()).toBe(true); 172 | }); 173 | }); 174 | 175 | describe("hasRealmRole", () => { 176 | it("should return true when user has the realm role", () => { 177 | mockDecode.mockReturnValue({ 178 | iss: "https://keycloak.example.org", 179 | sub: "user-123", 180 | aud: "client-app", 181 | exp: Math.floor(Date.now() / 1000) + 3600, 182 | iat: Math.floor(Date.now() / 1000), 183 | realm_access: { roles: ["user", "admin"] }, 184 | }); 185 | 186 | const token = new Token("valid.token"); 187 | 188 | expect(token.hasRealmRole("user")).toBe(true); 189 | expect(token.hasRealmRole("admin")).toBe(true); 190 | }); 191 | 192 | it("should return false when user does not have the realm role", () => { 193 | mockDecode.mockReturnValue({ 194 | iss: "https://keycloak.example.org", 195 | sub: "user-123", 196 | aud: "client-app", 197 | exp: Math.floor(Date.now() / 1000) + 3600, 198 | iat: Math.floor(Date.now() / 1000), 199 | realm_access: { roles: ["user"] }, 200 | }); 201 | 202 | const token = new Token("valid.token"); 203 | 204 | expect(token.hasRealmRole("admin")).toBe(false); 205 | }); 206 | 207 | it("should return false when realm_access is undefined", () => { 208 | mockDecode.mockReturnValue({ 209 | iss: "https://keycloak.example.org", 210 | sub: "user-123", 211 | aud: "client-app", 212 | exp: Math.floor(Date.now() / 1000) + 3600, 213 | iat: Math.floor(Date.now() / 1000), 214 | }); 215 | 216 | const token = new Token("valid.token"); 217 | 218 | expect(token.hasRealmRole("user")).toBe(false); 219 | }); 220 | 221 | it("should return false when realm_access.roles is undefined", () => { 222 | mockDecode.mockReturnValue({ 223 | iss: "https://keycloak.example.org", 224 | sub: "user-123", 225 | aud: "client-app", 226 | exp: Math.floor(Date.now() / 1000) + 3600, 227 | iat: Math.floor(Date.now() / 1000), 228 | realm_access: {}, 229 | }); 230 | 231 | const token = new Token("valid.token"); 232 | 233 | expect(token.hasRealmRole("user")).toBe(false); 234 | }); 235 | 236 | it("should return false when realm_access is null", () => { 237 | mockDecode.mockReturnValue({ 238 | iss: "https://keycloak.example.org", 239 | sub: "user-123", 240 | aud: "client-app", 241 | exp: Math.floor(Date.now() / 1000) + 3600, 242 | iat: Math.floor(Date.now() / 1000), 243 | realm_access: null, 244 | }); 245 | 246 | const token = new Token("valid.token"); 247 | 248 | expect(token.hasRealmRole("user")).toBe(false); 249 | }); 250 | }); 251 | 252 | describe("hasApplicationRole", () => { 253 | it("should return true when user has the application role", () => { 254 | mockDecode.mockReturnValue({ 255 | iss: "https://keycloak.example.org", 256 | sub: "user-123", 257 | aud: "client-app", 258 | exp: Math.floor(Date.now() / 1000) + 3600, 259 | iat: Math.floor(Date.now() / 1000), 260 | resource_access: { 261 | "my-app": { roles: ["viewer", "editor"] }, 262 | }, 263 | }); 264 | 265 | const token = new Token("valid.token"); 266 | 267 | expect(token.hasApplicationRole("my-app", "viewer")).toBe(true); 268 | expect(token.hasApplicationRole("my-app", "editor")).toBe(true); 269 | }); 270 | 271 | it("should return false when user does not have the application role", () => { 272 | mockDecode.mockReturnValue({ 273 | iss: "https://keycloak.example.org", 274 | sub: "user-123", 275 | aud: "client-app", 276 | exp: Math.floor(Date.now() / 1000) + 3600, 277 | iat: Math.floor(Date.now() / 1000), 278 | resource_access: { 279 | "my-app": { roles: ["viewer"] }, 280 | }, 281 | }); 282 | 283 | const token = new Token("valid.token"); 284 | 285 | expect(token.hasApplicationRole("my-app", "admin")).toBe(false); 286 | }); 287 | 288 | it("should return false when application does not exist", () => { 289 | mockDecode.mockReturnValue({ 290 | iss: "https://keycloak.example.org", 291 | sub: "user-123", 292 | aud: "client-app", 293 | exp: Math.floor(Date.now() / 1000) + 3600, 294 | iat: Math.floor(Date.now() / 1000), 295 | resource_access: { 296 | "my-app": { roles: ["viewer"] }, 297 | }, 298 | }); 299 | 300 | const token = new Token("valid.token"); 301 | 302 | expect(token.hasApplicationRole("other-app", "viewer")).toBe(false); 303 | }); 304 | 305 | it("should return false when resource_access is undefined", () => { 306 | mockDecode.mockReturnValue({ 307 | iss: "https://keycloak.example.org", 308 | sub: "user-123", 309 | aud: "client-app", 310 | exp: Math.floor(Date.now() / 1000) + 3600, 311 | iat: Math.floor(Date.now() / 1000), 312 | }); 313 | 314 | const token = new Token("valid.token"); 315 | 316 | expect(token.hasApplicationRole("my-app", "viewer")).toBe(false); 317 | }); 318 | 319 | it("should return false when resource_access is null", () => { 320 | mockDecode.mockReturnValue({ 321 | iss: "https://keycloak.example.org", 322 | sub: "user-123", 323 | aud: "client-app", 324 | exp: Math.floor(Date.now() / 1000) + 3600, 325 | iat: Math.floor(Date.now() / 1000), 326 | resource_access: null, 327 | }); 328 | 329 | const token = new Token("valid.token"); 330 | 331 | expect(token.hasApplicationRole("my-app", "viewer")).toBe(false); 332 | }); 333 | 334 | it("should return false when app roles is null", () => { 335 | mockDecode.mockReturnValue({ 336 | iss: "https://keycloak.example.org", 337 | sub: "user-123", 338 | aud: "client-app", 339 | exp: Math.floor(Date.now() / 1000) + 3600, 340 | iat: Math.floor(Date.now() / 1000), 341 | resource_access: { 342 | "my-app": null, 343 | }, 344 | }); 345 | 346 | const token = new Token("valid.token"); 347 | 348 | expect(token.hasApplicationRole("my-app", "viewer")).toBe(false); 349 | }); 350 | 351 | it("should return false when app roles.roles is undefined", () => { 352 | mockDecode.mockReturnValue({ 353 | iss: "https://keycloak.example.org", 354 | sub: "user-123", 355 | aud: "client-app", 356 | exp: Math.floor(Date.now() / 1000) + 3600, 357 | iat: Math.floor(Date.now() / 1000), 358 | resource_access: { 359 | "my-app": {}, 360 | }, 361 | }); 362 | 363 | const token = new Token("valid.token"); 364 | 365 | expect(token.hasApplicationRole("my-app", "viewer")).toBe(false); 366 | }); 367 | }); 368 | 369 | describe("token content access", () => { 370 | it("should expose all decoded token properties", () => { 371 | const mockPayload = { 372 | iss: "https://keycloak.example.org", 373 | sub: "user-123", 374 | aud: "client-app", 375 | exp: Math.floor(Date.now() / 1000) + 3600, 376 | iat: Math.floor(Date.now() / 1000), 377 | email: "test@example.com", 378 | preferred_username: "testuser", 379 | family_name: "Doe", 380 | given_name: "John", 381 | name: "John Doe", 382 | email_verified: true, 383 | custom_claim: "custom_value", 384 | }; 385 | 386 | mockDecode.mockReturnValue(mockPayload); 387 | 388 | const token = new Token("valid.token"); 389 | 390 | expect(token.content.iss).toBe("https://keycloak.example.org"); 391 | expect(token.content.sub).toBe("user-123"); 392 | expect(token.content.aud).toBe("client-app"); 393 | expect(token.content.email).toBe("test@example.com"); 394 | expect(token.content.preferred_username).toBe("testuser"); 395 | expect(token.content.family_name).toBe("Doe"); 396 | expect(token.content.given_name).toBe("John"); 397 | expect(token.content.name).toBe("John Doe"); 398 | expect(token.content.email_verified).toBe(true); 399 | expect(token.content.custom_claim).toBe("custom_value"); 400 | }); 401 | 402 | it("should support aud as array", () => { 403 | mockDecode.mockReturnValue({ 404 | iss: "https://keycloak.example.org", 405 | sub: "user-123", 406 | aud: ["client-app-1", "client-app-2"], 407 | exp: Math.floor(Date.now() / 1000) + 3600, 408 | iat: Math.floor(Date.now() / 1000), 409 | }); 410 | 411 | const token = new Token("valid.token"); 412 | 413 | expect(Array.isArray(token.content.aud)).toBe(true); 414 | expect(token.content.aud).toEqual(["client-app-1", "client-app-2"]); 415 | }); 416 | }); 417 | 418 | describe("edge cases for realm_access and resource_access", () => { 419 | it("should handle realm_access with null roles array", () => { 420 | mockDecode.mockReturnValue({ 421 | iss: "https://keycloak.example.org", 422 | sub: "user-123", 423 | aud: "client-app", 424 | exp: Math.floor(Date.now() / 1000) + 3600, 425 | iat: Math.floor(Date.now() / 1000), 426 | realm_access: { roles: null }, 427 | }); 428 | 429 | const token = new Token("valid.token"); 430 | 431 | expect(token.hasRealmRole("user")).toBe(false); 432 | }); 433 | 434 | it("should handle resource_access with null app entry", () => { 435 | mockDecode.mockReturnValue({ 436 | iss: "https://keycloak.example.org", 437 | sub: "user-123", 438 | aud: "client-app", 439 | exp: Math.floor(Date.now() / 1000) + 3600, 440 | iat: Math.floor(Date.now() / 1000), 441 | resource_access: { 442 | "my-app": null, 443 | }, 444 | }); 445 | 446 | const token = new Token("valid.token"); 447 | 448 | expect(token.hasApplicationRole("my-app", "viewer")).toBe(false); 449 | }); 450 | 451 | it("should handle resource_access with null roles in app", () => { 452 | mockDecode.mockReturnValue({ 453 | iss: "https://keycloak.example.org", 454 | sub: "user-123", 455 | aud: "client-app", 456 | exp: Math.floor(Date.now() / 1000) + 3600, 457 | iat: Math.floor(Date.now() / 1000), 458 | resource_access: { 459 | "my-app": { roles: null }, 460 | }, 461 | }); 462 | 463 | const token = new Token("valid.token"); 464 | 465 | expect(token.hasApplicationRole("my-app", "viewer")).toBe(false); 466 | }); 467 | 468 | it("should handle completely empty token with only required fields", () => { 469 | mockDecode.mockReturnValue({ 470 | iss: "https://keycloak.example.org", 471 | sub: "user-123", 472 | aud: "client-app", 473 | exp: Math.floor(Date.now() / 1000) + 3600, 474 | iat: Math.floor(Date.now() / 1000), 475 | }); 476 | 477 | const token = new Token("valid.token"); 478 | 479 | expect(token.content.iss).toBe("https://keycloak.example.org"); 480 | expect(token.content.sub).toBe("user-123"); 481 | expect(token.content.realm_access).toBeUndefined(); 482 | expect(token.content.resource_access).toBeUndefined(); 483 | expect(token.hasRealmRole("user")).toBe(false); 484 | expect(token.hasApplicationRole("app", "role")).toBe(false); 485 | }); 486 | }); 487 | }); 488 | --------------------------------------------------------------------------------