From 40eb51f10c294753dfd48c2e11f850ac0eab8715 Mon Sep 17 00:00:00 2001 From: rmartinc Date: Mon, 15 Dec 2025 16:46:09 +0100 Subject: [PATCH] Add timeout option for keycloak-admin-client Closes #42644 Signed-off-by: rmartinc --- js/libs/keycloak-admin-client/src/client.ts | 14 +++-- .../src/resources/agent.ts | 3 ++ .../test/timeout.spec.ts | 53 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 js/libs/keycloak-admin-client/test/timeout.spec.ts diff --git a/js/libs/keycloak-admin-client/src/client.ts b/js/libs/keycloak-admin-client/src/client.ts index f9ef32e2db7..48772377f5f 100644 --- a/js/libs/keycloak-admin-client/src/client.ts +++ b/js/libs/keycloak-admin-client/src/client.ts @@ -19,6 +19,8 @@ import { WhoAmI } from "./resources/whoAmI.js"; import { Credentials, getToken } from "./utils/auth.js"; import { defaultBaseUrl, defaultRealm } from "./utils/constants.js"; +export type RequestOptions = Omit; + export interface TokenProvider { getAccessToken: () => Promise; } @@ -26,8 +28,9 @@ export interface TokenProvider { export interface ConnectionConfig { baseUrl?: string; realmName?: string; - requestOptions?: RequestInit; + requestOptions?: RequestOptions; requestArgOptions?: Pick; + timeout?: number; } export class KeycloakAdminClient { @@ -56,14 +59,16 @@ export class KeycloakAdminClient { public scope?: string; public accessToken?: string; public refreshToken?: string; + public timeout?: number; - #requestOptions?: RequestInit; + #requestOptions?: RequestOptions; #globalRequestArgOptions?: Pick; #tokenProvider?: TokenProvider; constructor(connectionConfig?: ConnectionConfig) { this.baseUrl = connectionConfig?.baseUrl || defaultBaseUrl; this.realmName = connectionConfig?.realmName || defaultRealm; + this.timeout = connectionConfig?.timeout; this.#requestOptions = connectionConfig?.requestOptions; this.#globalRequestArgOptions = connectionConfig?.requestArgOptions; @@ -93,7 +98,10 @@ export class KeycloakAdminClient { realmName: this.realmName, scope: this.scope, credentials, - requestOptions: this.#requestOptions, + requestOptions: { + ...this.#requestOptions, + ...(this.timeout ? { signal: AbortSignal.timeout(this.timeout) } : {}), + }, }); this.accessToken = accessToken; this.refreshToken = refreshToken; diff --git a/js/libs/keycloak-admin-client/src/resources/agent.ts b/js/libs/keycloak-admin-client/src/resources/agent.ts index 9acf76aed8f..e85077d5fd7 100644 --- a/js/libs/keycloak-admin-client/src/resources/agent.ts +++ b/js/libs/keycloak-admin-client/src/resources/agent.ts @@ -248,6 +248,9 @@ export class Agent { ...requestOptions, headers: requestHeaders, method, + ...(this.#client.timeout + ? { signal: AbortSignal.timeout(this.#client.timeout) } + : {}), }); // now we get the response of the http request diff --git a/js/libs/keycloak-admin-client/test/timeout.spec.ts b/js/libs/keycloak-admin-client/test/timeout.spec.ts new file mode 100644 index 00000000000..8235ccf7360 --- /dev/null +++ b/js/libs/keycloak-admin-client/test/timeout.spec.ts @@ -0,0 +1,53 @@ +import { expect } from "chai"; +import { KeycloakAdminClient } from "../src/client.js"; +import { credentials } from "./constants.js"; +import { Server, createServer } from "node:http"; + +describe("Timeout", () => { + let server: Server; + + before(async () => { + server = createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/plain" }); + setTimeout(() => res.end("Hello, world!\n"), 1500); + }); + server.listen(8888, "localhost"); + }); + + after(async () => { + await server[Symbol.asyncDispose](); + }); + + void it("create without timeout", async () => { + const client = new KeycloakAdminClient({ + baseUrl: "http://localhost:8888", + }); + + try { + await client.auth(credentials); + } catch (error) { + expect(error).to.be.an("Error"); + expect((error as Error).message).to.contain("Unexpected token 'H'"); + return; + } + expect.fail(null, null, "auth did not fail"); + }); + + void it("create with timeout", async () => { + const client = new KeycloakAdminClient({ + baseUrl: "http://localhost:8888", + timeout: 1000, + }); + + try { + await client.auth(credentials); + } catch (error) { + expect(error).to.be.an("DOMException"); + expect((error as DOMException).message).to.contain( + "The operation was aborted due to timeout", + ); + return; + } + expect.fail(null, null, "auth did not fail"); + }); +});