import { DecodedJwt, createDecoder } from "fast-jwt"; const KC_URL = process.env.KC_URL; const KC_REALM = process.env.KC_REALM; const KC_ADMIN_USERNAME = process.env.KC_ADMIN_USERNAME; const KC_ADMIN_PASSWORD = process.env.KC_ADMIN_PASSWORD; 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 = 10) { decoded = jwtDecode(token); if (decoded && decoded.payload.exp) { return Date.now() / 1000 >= decoded.payload.exp - beforeExpire; } return true; } /** * Get token from keycloak if needed */ export async function getToken() { if (!KC_ADMIN_PASSWORD || !KC_ADMIN_USERNAME) { throw new Error("KC_ADMIN_USERNAME and KC_ADMIN_PASSWORD are required to used this feature."); } if (token && !isTokenExpired(token)) return token; const body = new URLSearchParams(); body.append("client_id", "admin-cli"); body.append("grant_type", "password"); body.append("username", KC_ADMIN_USERNAME); body.append("password", KC_ADMIN_PASSWORD); const res = await fetch(`${KC_URL}/realms/master/protocol/openid-connect/token`, { method: "POST", body: body, }).catch((e) => console.error(e)); if (!res) { throw new Error("Cannot get token from keycloak."); } const data = await res.json(); if (data && data.access_token) { token = data.access_token; } return token; } /** * Get keycloak user list * * @returns user list if success, false otherwise. */ export async function listUser(search = "", page = 1, pageSize = 30) { const res = await fetch( `${KC_URL}/admin/realms/${KC_REALM}/users?first=${(page - 1) * pageSize}&max=${pageSize}`.concat( !!search ? `&search=${search}` : "", ), { headers: { authorization: `Bearer ${await getToken()}`, "content-type": `application/json`, }, }, ).catch((e) => console.log("Keycloak Error: ", e)); if (!res) return; if (!res.ok) { return console.error("Keycloak Error Response: ", await res.json()); } return ((await res.json()) as any[]).map((v: Record) => ({ id: v.id, username: v.username, firstName: v.firstName, lastName: v.lastName, email: v.email, attributes: v.attributes, })); } /** * Count user in the system. Can be use for pagination purpose. * * @returns user count on success. */ export async function countUser(search = "") { const res = await fetch( `${KC_URL}/admin/realms/${KC_REALM}/users/count`.concat(!!search ? `?search=${search}` : ""), { headers: { authorization: `Bearer ${await getToken()}`, "content-type": `application/json`, }, }, ).catch((e) => console.log("Keycloak Error: ", e)); if (!res) return; if (!res.ok) { return console.error("Keycloak Error Response: ", await res.json()); } return (await res.json()) as number; } /** * 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) { const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users`, { // prettier-ignore headers: { "authorization": `Bearer ${await getToken()}`, "content-type": `application/json`, }, method: "POST", body: JSON.stringify({ enabled: opts?.enabled !== undefined ? opts.enabled : true, credentials: [{ type: "password", value: password }], 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())); } 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 editUser(userId: string, opts: Record) { const { password, ...rest } = opts; const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}`, { // prettier-ignore headers: { "authorization": `Bearer ${await getToken()}`, "content-type": `application/json`, }, method: "PUT", body: JSON.stringify({ enabled: opts?.enabled !== undefined ? opts.enabled : true, credentials: (password && [{ type: "password", value: opts?.password }]) || undefined, ...rest, }), }).catch((e) => console.log("Keycloak Error: ", e)); if (!res) return false; if (!res.ok) { return Boolean(console.error("Keycloak Error Response: ", 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 uuid or true if success, false otherwise. */ export async function deleteUser(userId: string) { const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}`, { // prettier-ignore headers: { "authorization": `Bearer ${await getToken()}`, "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())); } } /** * Get roles list or specific role data */ export async function listRole() { const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/roles?max=-1`, { headers: { authorization: `Bearer ${await getToken()}`, }, }).catch((e) => console.log(e)); if (!res) return; if (!res.ok && res.status !== 404) { return console.error("Keycloak Error Response: ", await res.json()); } if (res.status === 404) { return null; } const data = (await res.json()) as any[]; return data .map((v: Record) => ({ id: v.id, name: v.name })) .sort((a, b) => a.name.localeCompare(b.name)); } export async function getRoleByName(name: string) { const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/roles`.concat(`/${name}`), { headers: { authorization: `Bearer ${await getToken()}`, }, }).catch((e) => console.log(e)); if (!res) return; if (!res.ok && res.status !== 404) { return console.error("Keycloak Error Response: ", await res.json()); } if (res.status === 404) return null; const data = (await res.json()) as any; 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_REALM}/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(); if (Array.isArray(data)) { return data.map((v: Record) => ({ 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 }[]) { const res = await fetch( `${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/role-mappings/realm`, { // prettier-ignore headers: { "authorization": `Bearer ${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_REALM}/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; } export default { createUser, listRole, addUserRoles, removeUserRoles, };