import { DecodedJwt, createDecoder } from "fast-jwt"; const KC_URL = process.env.KC_URL; const KC_REALM = process.env.KC_REALM; const KC_CLIENT_ID = process.env.KC_SERVICE_ACCOUNT_CLIENT_ID; const KC_SECRET = process.env.KC_SERVICE_ACCOUNT_SECRET; 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 */ export async function getToken() { if (!KC_CLIENT_ID || !KC_SECRET) { throw new Error("KC_CLIENT_ID and KC_SECRET are required to used this feature."); } 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"); const res = await fetch(`${KC_URL}/realms/${KC_REALM}/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; } /** * 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: 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; } /** * 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) { const res = await fetch( `${KC_URL}/admin/realms/${KC_REALM}/roles`.concat((name && `/${name}`) || ""), { // 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, getRoles, addUserRoles, removeUserRoles, };