added ability to refresh token when within time (#45789) (#45813)
Some checks are pending
Keycloak CI / Version Compatibility Matrix (push) Waiting to run
Keycloak CI / Check conditional workflows and jobs (push) Waiting to run
Keycloak CI / Build (push) Blocked by required conditions
Keycloak CI / Base UT (push) Blocked by required conditions
Keycloak CI / Base IT (push) Blocked by required conditions
Keycloak CI / Adapter IT (push) Blocked by required conditions
Keycloak CI / Adapter IT Strict Cookies (push) Blocked by required conditions
Keycloak CI / Quarkus UT (push) Blocked by required conditions
Keycloak CI / Quarkus IT (push) Blocked by required conditions
Keycloak CI / Java Distribution IT/UT (push) Blocked by required conditions
Keycloak CI / Login Theme v1 tests (push) Blocked by required conditions
Keycloak CI / Volatile Sessions IT (push) Blocked by required conditions
Keycloak CI / External Infinispan IT (push) Blocked by required conditions
Keycloak CI / AuroraDB IT (push) Blocked by required conditions
Keycloak CI / AzureDB IT (push) Blocked by required conditions
Keycloak CI / Store IT (push) Blocked by required conditions
Keycloak CI / Store IT (additional) (push) Blocked by required conditions
Keycloak CI / Store Model Tests (push) Blocked by required conditions
Keycloak CI / Clustering IT (push) Blocked by required conditions
Keycloak CI / FIPS UT (push) Blocked by required conditions
Keycloak CI / FIPS IT (push) Blocked by required conditions
Keycloak CI / Forms IT (push) Blocked by required conditions
Keycloak CI / WebAuthn IT (push) Blocked by required conditions
Keycloak CI / SSSD (push) Blocked by required conditions
Keycloak CI / Migration Tests (push) Blocked by required conditions
Keycloak CI / Test Framework (push) Blocked by required conditions
Keycloak CI / Base IT (new) (push) Blocked by required conditions
Keycloak CI / Admin v2 (push) Blocked by required conditions
Keycloak CI / Cluster Compatibility Tests (push) Blocked by required conditions
Keycloak CI / Status Check - Keycloak CI (push) Blocked by required conditions
CodeQL / Check conditional workflows and jobs (push) Waiting to run
CodeQL / CodeQL Java (push) Blocked by required conditions
CodeQL / CodeQL JavaScript (push) Blocked by required conditions
CodeQL / CodeQL TypeScript (push) Blocked by required conditions
CodeQL / CodeQL GitHub Actions (push) Blocked by required conditions
CodeQL / Status Check - CodeQL (push) Blocked by required conditions
Keycloak Documentation / Check conditional workflows and jobs (push) Waiting to run
Keycloak Documentation / Build (push) Blocked by required conditions
Keycloak Documentation / External links check (push) Blocked by required conditions
Keycloak Documentation / Status Check - Keycloak Documentation (push) Blocked by required conditions
Keycloak Guides / Check conditional workflows and jobs (push) Waiting to run
Keycloak Guides / Build (push) Blocked by required conditions
Keycloak Guides / Status Check - Keycloak Guides (push) Blocked by required conditions
Keycloak JavaScript CI / Check conditional workflows and jobs (push) Waiting to run
Keycloak JavaScript CI / Build Keycloak (push) Blocked by required conditions
Keycloak JavaScript CI / Admin Client (push) Blocked by required conditions
Keycloak JavaScript CI / UI Shared (push) Blocked by required conditions
Keycloak JavaScript CI / Account UI (push) Blocked by required conditions
Keycloak JavaScript CI / Admin UI (push) Blocked by required conditions
Keycloak JavaScript CI / Account UI E2E (push) Blocked by required conditions
Keycloak JavaScript CI / Admin UI E2E (push) Blocked by required conditions
Keycloak JavaScript CI / Keycloak Admin Client (push) Blocked by required conditions
Keycloak JavaScript CI / Status Check - Keycloak JavaScript CI (push) Blocked by required conditions
Keycloak Operator CI / Check conditional workflows and jobs (push) Waiting to run
Keycloak Operator CI / Build distribution (push) Blocked by required conditions
Keycloak Operator CI / Test local apiserver (push) Blocked by required conditions
Keycloak Operator CI / Test remote (push) Blocked by required conditions
Keycloak Operator CI / Test OLM installation (push) Blocked by required conditions
Keycloak Operator CI / Status Check - Keycloak Operator CI (push) Blocked by required conditions

fixes: #44379


(cherry picked from commit ffc19d997e)

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Co-authored-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
This commit is contained in:
Stian Thorgersen 2026-01-28 09:00:02 +01:00 committed by GitHub
parent 9d0b171b3d
commit 05b7a79efa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 137 additions and 5 deletions

View file

@ -16,8 +16,9 @@ import { ServerInfo } from "./resources/serverInfo.js";
import { Users } from "./resources/users.js";
import { UserStorageProvider } from "./resources/userStorageProvider.js";
import { WhoAmI } from "./resources/whoAmI.js";
import { Credentials, getToken } from "./utils/auth.js";
import { Credentials, getToken, Settings } from "./utils/auth.js";
import { defaultBaseUrl, defaultRealm } from "./utils/constants.js";
import { DecodedToken, decodeToken } from "./utils/decode.js";
export type RequestOptions = Omit<RequestInit, "signal">;
@ -33,6 +34,8 @@ export interface ConnectionConfig {
timeout?: number;
}
const MIN_VALIDITY = 5; // in seconds
export class KeycloakAdminClient {
// Resources
public users: Users;
@ -64,6 +67,9 @@ export class KeycloakAdminClient {
#requestOptions?: RequestOptions;
#globalRequestArgOptions?: Pick<RequestArgs, "catchNotFound">;
#tokenProvider?: TokenProvider;
#accessTokenDecoded?: DecodedToken;
#refreshTokenDecoded?: DecodedToken;
#credentials?: Credentials;
constructor(connectionConfig?: ConnectionConfig) {
this.baseUrl = connectionConfig?.baseUrl || defaultBaseUrl;
@ -93,7 +99,16 @@ export class KeycloakAdminClient {
}
public async auth(credentials: Credentials) {
const { accessToken, refreshToken } = await getToken({
const { accessToken, refreshToken } = await getToken(
this.#getTokenSettings(credentials),
);
this.#credentials = credentials;
this.setAccessToken(accessToken);
this.setRefreshToken(refreshToken);
}
#getTokenSettings(credentials: Credentials): Settings {
return {
baseUrl: this.baseUrl,
realmName: this.realmName,
scope: this.scope,
@ -102,9 +117,7 @@ export class KeycloakAdminClient {
...this.#requestOptions,
...(this.timeout ? { signal: AbortSignal.timeout(this.timeout) } : {}),
},
});
this.accessToken = accessToken;
this.refreshToken = refreshToken;
};
}
public registerTokenProvider(provider: TokenProvider) {
@ -117,6 +130,12 @@ export class KeycloakAdminClient {
public setAccessToken(token: string) {
this.accessToken = token;
this.#accessTokenDecoded = decodeToken(token);
}
public setRefreshToken(token: string) {
this.refreshToken = token;
this.#refreshTokenDecoded = decodeToken(token);
}
public async getAccessToken() {
@ -124,9 +143,54 @@ export class KeycloakAdminClient {
return this.#tokenProvider.getAccessToken();
}
if (this.isTokenExpired()) {
await this.#refreshAccessToken();
}
return this.accessToken;
}
async #refreshAccessToken() {
if (!this.refreshToken || !this.#credentials) {
throw new Error(
"Cannot refresh token: missing refresh token or credentials",
);
}
if (this.isRefreshTokenExpired()) {
throw new Error("Cannot refresh token: refresh token has expired");
}
const { accessToken, refreshToken } = await getToken(
this.#getTokenSettings({
grantType: "refresh_token",
clientId: this.#credentials.clientId,
clientSecret: this.#credentials.clientSecret,
refreshToken: this.refreshToken,
}),
);
this.setAccessToken(accessToken);
this.setRefreshToken(refreshToken);
}
public isTokenExpired(): boolean {
return this.#isExpired(this.#accessTokenDecoded);
}
public isRefreshTokenExpired(): boolean {
return this.#isExpired(this.#refreshTokenDecoded);
}
#isExpired(token?: DecodedToken): boolean {
if (typeof token?.exp !== "number") {
return false;
}
const expiresIn =
token.exp - Math.ceil(new Date().getTime() / 1000) - MIN_VALIDITY;
return expiresIn < 0;
}
public getRequestOptions() {
return this.#requestOptions;
}

View file

@ -0,0 +1,68 @@
export interface DecodedToken {
exp?: number;
}
export function decodeToken(token: string): DecodedToken {
const [, payload] = token.split(".");
if (typeof payload !== "string") {
throw new Error("Unable to decode token, payload not found.");
}
let decoded;
try {
decoded = base64UrlDecode(payload);
} catch (error) {
throw new Error(
"Unable to decode token, payload is not a valid Base64URL value.",
{ cause: error },
);
}
try {
return JSON.parse(decoded);
} catch (error) {
throw new Error(
"Unable to decode token, payload is not a valid JSON value.",
{ cause: error },
);
}
}
function base64UrlDecode(input: string): string {
let output = input.replaceAll("-", "+").replaceAll("_", "/");
switch (output.length % 4) {
case 0:
break;
case 2:
output += "==";
break;
case 3:
output += "=";
break;
default:
throw new Error("Input is not of the correct length.");
}
try {
return b64DecodeUnicode(output);
} catch {
return atob(output);
}
}
function b64DecodeUnicode(input: string): string {
return decodeURIComponent(
atob(input).replace(/(.)/g, (m, p) => {
let code = p.charCodeAt(0).toString(16).toUpperCase();
if (code.length < 2) {
code = "0" + code;
}
return "%" + code;
}),
);
}