1246 lines
33 KiB
TypeScript
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;
|
|
}
|