jws-backend/src/services/keycloak.ts
2025-04-24 15:10:42 +07:00

412 lines
11 KiB
TypeScript

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<string, string>) => ({
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<string, any>) {
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<string, any>) {
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<string, string>) => ({ 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<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 }[]) {
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 async function getGroup(query: string) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/groups?${query}`, {
headers: {
authorization: `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
method: "GET",
});
const dataMainGroup = await res.json();
const fetchSubGroups = async (group: any) => {
let fullSubGroup = await Promise.all(
group.subGroups.map((subGroupsData: any) => {
if (group.subGroupCount > 0) {
return fetchSubGroups(subGroupsData);
} else {
return {
id: subGroupsData.id,
name: subGroupsData.name,
path: subGroupsData.path,
subGroupCount: subGroupsData.subGroupCount,
subGroups: [],
};
}
}),
);
return {
id: group.id,
name: group.name,
path: group.path,
subGroupCount: group.subGroupCount,
subGroups: fullSubGroup,
};
};
const fullMainGroup = await Promise.all(dataMainGroup.map(fetchSubGroups));
return fullMainGroup;
}
export async function getGroupUser(userId: string) {
const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/groups`, {
headers: {
authorization: `Bearer ${await getToken()}`,
"content-type": `application/json`,
},
method: "GET",
});
const data = await res.json();
return data.map((item: any) => {
return {
id: item.id,
name: item.name,
path: item.path,
};
});
}
export default {
createUser,
listRole,
addUserRoles,
removeUserRoles,
};