hrms-api-org/src/keycloak/index.ts

1246 lines
33 KiB
TypeScript

import { DecodedJwt, createDecoder } from "fast-jwt";
/**
* RateLimiter
* Limits the rate of API calls to avoid overwhelming the server
*/
export class RateLimiter {
private requestsPerSecond: number;
private requestTimes: number[] = [];
constructor(requestsPerSecond: number = 10) {
this.requestsPerSecond = requestsPerSecond;
}
/**
* Throttle requests to stay within rate limit
* Waits if rate limit would be exceeded
*/
async throttle(): Promise<void> {
const now = Date.now();
// Remove timestamps older than 1 second
this.requestTimes = this.requestTimes.filter((t) => now - t < 1000);
if (this.requestTimes.length >= this.requestsPerSecond) {
const oldestRequest = this.requestTimes[0];
const waitTime = 1000 - (now - oldestRequest);
if (waitTime > 0) {
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
}
this.requestTimes.push(Date.now());
}
/**
* Reset the rate limiter (e.g., after a long pause)
*/
reset(): void {
this.requestTimes = [];
}
}
/**
* Check if an error is a network error (retryable)
* @param error - Error to check
* @returns true if error is network-related and retryable
*/
function isNetworkError(error: any): boolean {
if (!error) return false;
// Check for fetch network errors
if (error.name === "TypeError" && error.message.includes("fetch")) {
return true;
}
// Check for ECONNREFUSED, ETIMEDOUT, etc.
if (error.code && ["ECONNREFUSED", "ETIMEDOUT", "ECONNRESET", "ENOTFOUND"].includes(error.code)) {
return true;
}
return false;
}
/**
* Check if an HTTP status code is retryable
* @param status - HTTP status code
* @returns true if status code indicates a temporary error
*/
function isRetryableStatus(status: number): boolean {
// Retry on 5xx errors (server errors) and 429 (rate limit)
return status >= 500 || status === 429;
}
/**
* Retry wrapper with exponential backoff
* Retries failed operations with increasing delay between attempts
*
* @param fn - Function to execute
* @param maxRetries - Maximum number of retry attempts
* @param baseDelay - Base delay in milliseconds (doubles each retry)
* @returns Promise with result of fn
*/
export async function withRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000,
): Promise<T> {
let lastError: any;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error: any) {
lastError = error;
// Check if error is retryable
const isRetryable = isNetworkError(error) || isRetryableStatus(error?.status);
if (!isRetryable) {
// Don't retry on permanent errors (4xx except 429)
throw error;
}
if (attempt < maxRetries) {
// Calculate delay with exponential backoff
const delay = baseDelay * Math.pow(2, attempt);
console.log(
`[withRetry] Attempt ${attempt + 1}/${maxRetries + 1} failed, retrying in ${delay}ms...`,
error.message || error,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
/**
* Fetch with timeout
* Aborts request if it takes longer than specified timeout
*/
async function fetchWithTimeout(
url: RequestInfo | URL,
options: RequestInit = {},
timeout: number = 10000,
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
throw new Error(`Request timeout after ${timeout}ms`);
}
throw error;
}
}
const KC_URL = process.env.KC_URL;
const KC_REALMS = process.env.KC_REALMS;
const KC_CLIENT_ID = process.env.KC_SERVICE_ACCOUNT_CLIENT_ID;
const KC_SECRET = process.env.KC_SERVICE_ACCOUNT_SECRET;
const AUTH_ACCOUNT_SECRET = process.env.AUTH_ACCOUNT_SECRET;
const API_KEY = process.env.API_KEY;
let token: string | null = null;
let decoded: DecodedJwt | null = null;
const jwtDecode = createDecoder({ complete: true });
/**
* Check if token is expired or will expire in 30 seconds
* @returns true if expire or can't get exp, false otherwise
*/
export function isTokenExpired(token: string, beforeExpire: number = 30) {
decoded = jwtDecode(token);
if (decoded && decoded.payload.exp) {
return Date.now() / 1000 >= decoded.payload.exp - beforeExpire;
}
return true;
}
/**
* Get token from keycloak if needed
* Returns null if Keycloak is unavailable
*/
export async function getToken(): Promise<string | null> {
if (!KC_CLIENT_ID || !KC_SECRET) {
console.error("[getToken] KC_CLIENT_ID and KC_SECRET are required");
return null;
}
if (token && !isTokenExpired(token)) return token;
const body = new URLSearchParams();
body.append("client_id", KC_CLIENT_ID);
body.append("client_secret", KC_SECRET);
body.append("grant_type", "client_credentials");
try {
const res = await fetchWithTimeout(
`${KC_URL}/realms/${KC_REALMS}/protocol/openid-connect/token`,
{
method: "POST",
body: body,
},
10000,
);
if (!res.ok) {
console.error(`[getToken] Keycloak token request failed: ${res.status}`);
return null;
}
const data = (await res.json()) as any;
if (data && data.access_token) {
token = data.access_token;
console.log(`[getToken] Token refreshed successfully`);
return token;
}
console.error("[getToken] No access_token in response");
return null;
} catch (error: any) {
console.error(`[getToken] Failed to get token: ${error.message}`);
return null;
}
}
/**
* Create keycloak user by given username and password with roles
*
* Client must have permission to manage realm's user
*
* @returns user uuid or true if success, false otherwise.
*/
export async function createUser(
username: string,
password: string,
opts?: Record<string, any>,
token?: string,
) {
const authToken = token || (await getToken());
if (!authToken) {
console.error("[createUser] Failed to get Keycloak token");
return false;
}
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${authToken}`,
"content-type": `application/json`,
},
method: "POST",
body: JSON.stringify({
enabled: true,
credentials: [{ type: "password", value: password, temporary: false }],
username,
...opts,
}),
}).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
return await res.json();
}
const path = res.headers.get("Location");
const id = path?.split("/").at(-1);
return id || true;
}
/**
* Get keycloak user by uuid
*
* Client must have permission to manage realm's user
*
* @returns user if success, false otherwise.
*/
export async function getUser(userId: string, token?: string) {
const authToken = token || (await getToken());
if (!authToken) {
console.error("[getUser] Failed to get Keycloak token");
return false;
}
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${authToken}`,
"content-type": `application/json`,
},
}).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return await res.json();
}
/**
* Get keycloak user by Username (citizenId)
*
* Client must have permission to manage realm's user
*
* @returns user if success, false otherwise.
*/
export async function getUserByUsername(citizenId: string, token?: string) {
const authToken = token || (await getToken());
if (!authToken) {
console.error("[getUserByUsername] Failed to get Keycloak token");
return false;
}
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users?username=${citizenId}`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${authToken}`,
"content-type": `application/json`,
},
}).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return await res.json();
}
/**
* Get keycloak user list
*
* Client must have permission to manage realm's user
*
* @returns user list if success, false otherwise.
*/
export async function getUserList(first = "", max = "", search = "") {
const res = await fetch(
// `${KC_URL}/admin/realms/${KC_REALMS}/users`.concat(!!search ? `?search=${search}` : ""),
`${KC_URL}/admin/realms/${KC_REALMS}/users?first=${first || "0"}&max=${max || "-1"}${search ? `&search=${search}` : ""}`,
{
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
},
).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return ((await res.json()) as any[]).map((v: Record<string, string>) => ({
id: v.id,
username: v.username,
firstName: v.firstName,
lastName: v.lastName,
email: v.email,
enabled: v.enabled,
}));
}
export async function getUserCount(first = "", max = "", search = "") {
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALMS}/users/count?first=${first || "0"}&max=${max || "-1"}${search ? `&search=${search}` : ""}`,
{
headers: {
authorization: `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
},
).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return await res.json();
}
/**
* Get keycloak user list
*
* Client must have permission to manage realm's user
*
* @returns user list if success, false otherwise.
*/
export async function getUserListOrg(first = "", max = "", search = "", userIds: string[] = []) {
const userIdsParam = userIds.join(",");
const res = await fetch(
// `${KC_URL}/admin/realms/${KC_REALMS}/users`.concat(!!search ? `?search=${search}` : ""),
`${KC_URL}/admin/realms/${KC_REALMS}/users?first=${first || "0"}&max=${max || "-1"}${search ? `&search=${search}` : ""}${userIdsParam ? `&id=${userIdsParam}` : ""}`,
{
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
},
).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return ((await res.json()) as any[]).map((v: Record<string, string>) => ({
id: v.id,
username: v.username,
firstName: v.firstName,
lastName: v.lastName,
email: v.email,
enabled: v.enabled,
}));
}
export async function getUserCountOrg(first = "", max = "", search = "", userIds: string[] = []) {
const userIdsParam = userIds.join(",");
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALMS}/users/count?first=${first || "0"}&max=${max || "-1"}${search ? `&search=${search}` : ""}${userIdsParam && userIdsParam != "" ? `&id=${userIdsParam}` : ""}`,
{
headers: {
authorization: `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
},
).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return await res.json();
}
/**
* Update keycloak user by uuid
*
* Client must have permission to manage realm's user
*
* @returns user uuid or true if success, false otherwise.
*/
export async function editUser(userId: string, opts: Record<string, any>) {
const { password, ...rest } = opts;
const token = await getToken();
if (!token) {
console.error("[editUser] Failed to get Keycloak token");
return false;
}
// Get existing user data to preserve other fields
const existingUser = await getUser(userId, token);
if (!existingUser) {
console.error(`[editUser] User ${userId} not found in Keycloak`);
return false;
}
// Merge existing user data with updated fields
const updatedUser = {
...existingUser,
...rest,
credentials: (password && [{ type: "password", value: opts?.password }]) || undefined,
};
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${token}`,
"content-type": `application/json`,
},
method: "PUT",
body: JSON.stringify(updatedUser),
}).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
return await res.json();
}
const path = res.headers.get("Location");
const id = path?.split("/").at(-1);
return id || true;
}
/**
* Update keycloak user by uuid
*
* Client must have permission to manage realm's user
*
* @returns user uuid or true if success, false otherwise.
*/
export async function updateName(
userId: string,
firstName: string,
lastName: string,
prefix: string,
// opts: Record<string, any>,
) {
// const { password, ...rest } = opts;
// Get existing user data to preserve other fields
const existingUser = await getUser(userId);
if (!existingUser) {
console.error(`[updateName] User ${userId} not found in Keycloak`);
return false;
}
// Merge existing user data with updated name fields
const updatedUser = {
...existingUser,
firstName,
lastName,
attributes: {
...(existingUser.attributes || {}),
prefix,
},
};
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
method: "PUT",
body: JSON.stringify(updatedUser),
}).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
// return Boolean(console.error("Keycloak Error Response: ", await res.json()));
return await res.json();
}
const path = res.headers.get("Location");
const id = path?.split("/").at(-1);
return id || true;
}
/**
* enable keycloak user by uuid
*
* Client must have permission to manage realm's user
*
* @returns user uuid or true if success, false otherwise.
*/
export async function enableStatus(userId: string, status: boolean) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}`, {
headers: {
authorization: `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
method: "PUT",
body: JSON.stringify({
enabled: status,
}),
}).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
return await res.json();
}
const path = res.headers.get("Location");
const id = path?.split("/").at(-1);
return id || true;
}
/**
* Delete keycloak user by uuid
*
* Client must have permission to manage realm's user
*
* @returns user true if success, false otherwise.
*/
export async function deleteUser(userId: string, token?: string) {
const authToken = token || (await getToken());
if (!authToken) {
console.error("[deleteUser] Failed to get Keycloak token");
return false;
}
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${authToken}`,
"content-type": `application/json`,
},
method: "DELETE",
}).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return true;
}
/**
* Get keycloak user by uuid
*
* Client must have permission to manage realm's user
*
* @returns user if success, false otherwise.
*/
export async function getRoleMappings(userId: string) {
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}/role-mappings/realm`,
{
headers: {
authorization: `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
},
).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return await res.json();
}
/**
* Get roles list or specific role data
*
* Client must have permission to get realms roles
*
* @returns role's info (array if not specify name) if success, null if not found, false otherwise.
*/
export async function getRoles(name?: string, token?: string) {
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALMS}/roles`.concat((name && `/${name}`) || ""),
{
// prettier-ignore
headers: {
"authorization": `Bearer ${token || await getToken()}`,
},
},
).catch((e) => console.log(e));
if (!res) return false;
if (!res.ok && res.status !== 404) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
if (res.status === 404) {
return null;
}
const data = (await res.json()) as any;
if (Array.isArray(data)) {
return data.map((v: Record<string, string>) => ({
id: v.id,
name: v.name,
description: v.description,
}));
}
// return {
// id: data.id,
// name: data.name,
// };
}
/**
* Get roles list of user
*
* Client must have permission to get realms roles
*
* @returns role's info (array if not specify name) if success, null if not found, false otherwise.
*/
export async function getUserRoles(userId: string) {
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}/role-mappings/realm`,
{
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
},
},
).catch((e) => console.log(e));
if (!res) return false;
if (!res.ok && res.status !== 404) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
if (res.status === 404) {
return null;
}
const data = (await res.json()) as any;
if (Array.isArray(data)) {
return data.map((v: Record<string, string>) => ({ id: v.id, name: v.name }));
}
return {
id: data.id,
name: data.name,
};
}
/**
* Assign role to user
*
* Client must have permission to manage realm's user roles
*
* @returns true if success, false otherwise.
*/
export async function addUserRoles(
userId: string,
roles: { id: string; name: string }[],
token?: string,
) {
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}/role-mappings/realm`,
{
// prettier-ignore
headers: {
"authorization": `Bearer ${token || await getToken()}`,
"content-type": `application/json`,
},
method: "POST",
body: JSON.stringify(roles),
},
).catch((e) => console.log(e));
if (!res) return false;
if (!res.ok) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return true;
}
/**
* Remove role from user
*
* Client must have permission to manage realm's user roles
*
* @returns true if success, false otherwise.
*/
export async function removeUserRoles(userId: string, roles: { id: string; name: string }[]) {
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}/role-mappings/realm`,
{
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
method: "DELETE",
body: JSON.stringify(roles),
},
).catch((e) => console.log(e));
if (!res) return false;
if (!res.ok) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return true;
}
/**
* Get group list or specific group data
*
* Client must have permission to manage realms group
*
* @returns group's info (array if not specify name) if success, null if not found, false otherwise.
*/
export async function getGroups(id?: string) {
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALMS}/groups`.concat((id && `/${id}`) || ""),
{
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
},
},
).catch((e) => console.log(e));
if (!res) return false;
if (!res.ok && res.status !== 404) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
if (res.status === 404) {
return null;
}
const data = (await res.json()) as any;
if (Array.isArray(data)) {
return data.map((v: Record<string, string>) => ({ id: v.id, name: v.name }));
}
return {
id: data.id,
name: data.name,
};
}
/**
* Create group
*
* Client must have permission to manage realms group
*
* @returns true if success, false otherwise.
*/
export async function createGroup(name: string) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/groups`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
method: "POST",
body: JSON.stringify({ name }),
}).catch((e) => console.log(e));
if (!res) return false;
if (!res.ok && res.status !== 404) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return true;
}
/**
* Edit group
*
* Client must have permission to manage realms group
*
* @returns true if success, false otherwise.
*/
export async function editGroup(id: string, name: string) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/groups/${id}`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
method: "PUT",
body: JSON.stringify({ name }),
}).catch((e) => console.log(e));
if (!res) return false;
if (!res.ok && res.status !== 404) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return true;
}
/**
* Delete group
*
* Client must have permission to manage realms group
*
* @returns true if success, false otherwise.
*/
export async function deleteGroup(id: string) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/groups/${id}`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
},
method: "DELETE",
}).catch((e) => console.log(e));
if (!res) return false;
if (!res.ok && res.status !== 404) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return true;
}
/**
* Get group list or specific group data
*
* Client must have permission to manage realms group
*
* @returns group's info (array if not specify name) if success, null if not found, false otherwise.
*/
export async function getUserGroups(userId?: string) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}/groups`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
},
}).catch((e) => console.log(e));
if (!res) return false;
if (!res.ok && res.status !== 404) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
if (res.status === 404) {
return null;
}
const data = (await res.json()) as any;
if (Array.isArray(data)) {
return data.map((v: Record<string, string>) => ({ id: v.id, name: v.name }));
}
return {
id: data.id,
name: data.name,
};
}
/**
* Add group to user
*
* Client must have permission to manage user group
*
* @returns true if success, false otherwise.
*/
export async function addUserGroup(userId: string, groupId: string) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}/groups/${groupId}`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
},
method: "PUT",
}).catch((e) => console.log(e));
if (!res) return false;
if (!res.ok && res.status !== 404) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return true;
}
/**
* Delete group from user
*
* Client must have permission to manage user group
*
* @returns true if success, false otherwise.
*/
export async function removeUserGroup(userId: string, groupId: string) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}/groups/${groupId}`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${await getToken()}`,
},
method: "PUT",
}).catch((e) => console.log(e));
if (!res) return false;
if (!res.ok && res.status !== 404) {
return Boolean(console.error("Keycloak Error Response: ", await res.json()));
}
return true;
}
// Function to change user password
export async function changeUserPassword(userId: string, newPassword: string) {
try {
const token = await getToken();
if (!token) {
console.error("[changeUserPassword] Failed to get Keycloak token");
return false;
}
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}/reset-password`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${token}`,
"content-type": `application/json`,
},
method: "PUT",
body: JSON.stringify({
type: "password",
value: newPassword,
temporary: false, // Set to true if the user must reset their password on next login
}),
}).catch((e) => console.log("Keycloak Error: ", e));
if (!res) {
console.error("[changeUserPassword] No response from Keycloak");
return false;
}
if (!res.ok) {
console.error(`[changeUserPassword] Failed to change password: ${res.status}`);
return false;
}
return true;
} catch (error) {
console.error("Error changing password:", error);
return false;
}
}
// Function to reset password
export async function resetPassword(username: string) {
try {
const token = await getToken();
if (!token) {
console.error("[resetPassword] Failed to get Keycloak token");
return false;
}
const users = await fetchWithTimeout(
`${KC_URL}/admin/realms/${KC_REALMS}/users?email=${encodeURIComponent(username)}`,
{
headers: {
authorization: `Bearer ${token}`,
"content-type": `application/json`,
},
},
10000,
);
if (!users.ok) {
const errorText = await users.text();
console.error(`[resetPassword] Failed to search user. Status: ${users.status}, Error: ${errorText}`);
return false;
}
const usersData = await users.json();
if (!usersData || usersData.length === 0) {
console.error(`[resetPassword] User not found with email: ${username}`);
return false;
}
const userId = usersData[0].id;
const resetResponse = await fetchWithTimeout(
`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}/execute-actions-email`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify(["UPDATE_PASSWORD"]),
},
10000,
);
if (!resetResponse.ok) {
const errorText = await resetResponse.text();
console.error(`[resetPassword] Failed to send reset email. Status: ${resetResponse.status}, Error: ${errorText}`);
return false;
}
console.log(`[resetPassword] Password reset email sent successfully to: ${username}`);
return { message: "Password reset email sent" };
} catch (error: any) {
console.error(`[resetPassword] Error triggering password reset: ${error.message}`);
return false;
}
}
export async function updateUserAttributes(
userId: string,
attributes: Record<string, string[]>,
): Promise<boolean> {
try {
const token = await getToken();
if (!token) {
console.error("[updateUserAttributes] Failed to get Keycloak token");
return false;
}
// Get existing user data to preserve other attributes
const existingUser = await getUser(userId, token);
if (!existingUser) {
console.error(`User ${userId} not found in Keycloak`);
return false;
}
// Merge existing attributes with new attributes
// IMPORTANT: Spread all existing user fields to preserve firstName, lastName, email, etc.
// The Keycloak PUT endpoint performs a full update, so we must include all fields
const updatedAttributes = {
...existingUser,
attributes: {
...(existingUser.attributes || {}),
...attributes,
},
};
console.log(
`[updateUserAttributes] Sending to Keycloak:`,
JSON.stringify(updatedAttributes, null, 2),
);
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users/${userId}`, {
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
method: "PUT",
body: JSON.stringify(updatedAttributes),
}).catch((e) => {
console.error(`[updateUserAttributes] Network error:`, e);
return null;
});
if (!res) {
console.error(`[updateUserAttributes] No response from Keycloak`);
return false;
}
if (!res.ok) {
const errorText = await res.text();
console.error(`[updateUserAttributes] Keycloak Error (${res.status}):`, errorText);
return false;
}
console.log(`[updateUserAttributes] Successfully updated attributes for user ${userId}`);
return true;
} catch (error) {
console.error(`[updateUserAttributes] Error updating attributes for user ${userId}:`, error);
return false;
}
}
export async function getAllUsersPaginated(
search: string = "",
batchSize: number = 100,
): Promise<
| Array<{
id: string;
username: string;
firstName?: string;
lastName?: string;
email?: string;
enabled: boolean;
}>
| false
> {
const allUsers: any[] = [];
let first = 0;
let hasMore = true;
while (hasMore) {
const res = await fetch(
`${KC_URL}/admin/realms/${KC_REALMS}/users?first=${first}&max=${batchSize}${search ? `&search=${search}` : ""}`,
{
headers: {
authorization: `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
},
).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
const errorText = await res.text();
console.error("Keycloak Error Response: ", errorText);
return false;
}
const rawText = await res.text();
try {
const batch = JSON.parse(rawText) as any[];
if (batch.length === 0) {
hasMore = false;
} else {
allUsers.push(...batch);
first += batch.length;
hasMore = batch.length === batchSize;
// Log progress for large datasets
if (allUsers.length % 500 === 0) {
console.log(`[getAllUsersPaginated] Fetched ${allUsers.length} users so far...`);
}
}
} catch (error) {
console.error(`[getAllUsersPaginated] Failed to parse JSON response at offset ${first}:`);
console.error(
`[getAllUsersPaginated] Response preview (first 500 chars):`,
rawText.substring(0, 500),
);
console.error(
`[getAllUsersPaginated] Response preview (last 200 chars):`,
rawText.slice(-200),
);
throw new Error(`Failed to parse Keycloak response as JSON at batch starting at ${first}.`);
}
}
console.log(`[getAllUsersPaginated] Total users fetched: ${allUsers.length}`);
return allUsers.map((v: any) => ({
id: v.id,
username: v.username,
firstName: v.firstName,
lastName: v.lastName,
email: v.email,
enabled: v.enabled === true || v.enabled === "true",
}));
}
/**
* Create keycloak user by given username and password with roles
*
* Client must have permission to manage realm's user
*
* @returns user uuid or true if success, false otherwise.
*/
export async function createUserHaveProfile(
username: string,
password: string,
profileId: string,
prefix: string,
opts?: Record<string, any>,
token?: string,
) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALMS}/users`, {
// prettier-ignore
headers: {
"authorization": `Bearer ${token || await getToken()}`,
"content-type": `application/json`,
},
method: "POST",
body: JSON.stringify({
enabled: true,
credentials: [{ type: "password", value: password, temporary: false }],
username,
...opts,
}),
}).catch((e) => console.log("Keycloak Error: ", e));
if (!res) return false;
if (!res.ok) {
// return Boolean(console.error("Keycloak Error Response: ", await res.json()));
return await res.json();
}
const path = res.headers.get("Location");
const id = path?.split("/").at(-1);
return id || true;
}