fp/services/next/app/lib/keycloak.ts

211 lines
7.8 KiB
TypeScript

import { type Tier, type TiersList, tiers } from './patreon'
import { configs } from '../config/configs';
export interface UMAToken {
upgraded: boolean;
access_token: string;
expires_in: number;
refresh_expires_in: number;
token_type: string;
"not-before-policy": number;
}
export interface KeycloakGroup {
id: string;
name: string;
path: string;
subGroups: KeycloakGroup[];
}
// Maps patreon tiers to keycloak groups.
export const patreonToKeycloakMap: Record<string, string> = {
everyone: 'ccaece3a-bb62-4fc9-be1d-6326ecdec65c',
free: 'ccaece3a-bb62-4fc9-be1d-6326ecdec65c',
archiveSupporter: '9e70fe60-5015-44df-ab77-d93b90e86738',
stealthSupporter: 'fd5358fe-d5e8-43be-85f2-27ddff711bd0',
tuneItUp: '4127e407-0c09-4c0a-b231-0bc058fb3dbd',
maxQ: '25b3d3af-2015-49ea-949f-b052fc82c6a3',
archiveCollector: '9e70fe60-5015-44df-ab77-d93b90e86738',
advancedArchiveSupporter: '7f875c63-4c74-4ae1-b33c-eefa7904d031',
quantumSupporter: '13076175-3a6f-4d80-84f2-6a8d5711e5f5',
sneakyQuantumSupporter: '13076175-3a6f-4d80-84f2-6a8d5711e5f5',
luberPlusPlus: '205d9c95-68ec-4b20-a6c1-28162af8a8f5'
};
export function getTierNameFromTierId(tierId: string): string {
const tierName = Object.entries(tiers).find(([_, id]) => id === tierId)?.[0];
if (!tierName) throw new Error(`getTierNameFromTierId failed to convert tierId=${tierId} into a tier name.`);
return tierName
}
export function getKeycloakIdFromTierId(tierId: string): string | undefined {
// Find the tier name corresponding to the provided tier ID
const tierName = getTierNameFromTierId(tierId)
if (!tierName) {
console.warn(`Tier ID ${tierId} not found in tiers map.`);
return undefined; // Return undefined if the tier ID is not found
}
// Use the tier name to get the corresponding Keycloak group ID
const keycloakId = patreonToKeycloakMap[tierName];
if (!keycloakId) {
console.warn(`Keycloak ID for tier ${tierName} not found in patreonToKeycloakMap.`);
}
return keycloakId;
}
/**
* This gets us an access_token with our service-account-futureporn user privs.
* This service account has manage-users role so it can modify their group memebership.
*/
export async function getUMAToken(): Promise<UMAToken> {
const res = await fetch(`${configs.keycloakLocalUrl}/realms/futureporn/protocol/openid-connect/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:uma-ticket',
client_id: configs.keycloakClientId,
client_secret: configs.keycloakClientSecret,
audience: 'futureporn'
}).toString()
})
const data = await res.json()
if (!res.ok) {
throw new Error(`failed to getUMAToken. body=${JSON.stringify(data, null, 2)}, res.status=${res.status}, res.statusText=${res.statusText}`);
}
return data
}
export async function getUserGroups(uma: UMAToken, userId: string): Promise<string[]> {
if (!uma) throw new Error('getUserGroups() requires a UMAToken as second param, but it was undefined.');
if (!userId) throw new Error('getUserGroups() requires a userId as second param, but it was undefined.');
const res = await fetch(`${configs.keycloakLocalUrl}/admin/realms/futureporn/users/${userId}/groups`, {
headers: {
'Authorization': `Bearer ${uma.access_token}`
}
})
const data = await res.json() as KeycloakGroup[]
if (!res.ok) {
throw new Error(`failed to getUserGroups. body=${JSON.stringify(data, null, 2)}, res.status=${res.status}, res.statusText=${res.statusText}`);
}
return data.map((g) => g.id)
}
export async function addUserGroup(uma: UMAToken, userId: string, groupId: string): Promise<void> {
const res = await fetch(`${configs.keycloakLocalUrl}/admin/realms/futureporn/users/${userId}/groups/${groupId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${uma.access_token}`
}
})
if (!res.ok) {
throw new Error(`failed to addUserGroup. res.status=${res.status}, res.statusText=${res.statusText}`);
}
}
export async function removeUserGroup(uma: UMAToken, userId: string, groupId: string): Promise<void> {
const res = await fetch(`${configs.keycloakLocalUrl}/admin/realms/futureporn/users/${userId}/groups/${groupId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${uma.access_token}`
}
})
if (!res.ok) {
throw new Error(`failed to removeUserGroup. res.status=${res.status}, res.statusText=${res.statusText}`);
}
}
/**
* Identifies the changes required to sync the actual state with the desired state.
*
* @param desired - Array of desired group IDs.
* @param actual - Array of actual group IDs.
* @returns An object with:
* - `toAdd`: Groups in `desired` but not in `actual` (need to be added).
* - `toRemove`: Groups in `actual` but not in `desired` (need to be removed).
*/
export function calculateSyncChanges(desired: string[], actual: string[]) {
const toAdd = Array.from(new Set(desired.filter(item => !actual.includes(item))));
const toRemove = Array.from(new Set(actual.filter(item => !desired.includes(item))));
return { toAdd, toRemove };
}
export async function syncronizeKeycloakRoles(keycloakUserId: string, patreonTiersList: TiersList): Promise<void> {
if (!keycloakUserId) throw new Error('syncronizeKeycloakRoles() requires userId as first param, but it was undefined.');
if (!patreonTiersList) throw new Error('patreonTiersList() requires userId as second param, but it was undefined.');
// 1. [-] get Keycloak service account uma token
// POST https://keycloak.fp.sbtp.xyz/realms/futureporn/protocol/openid-connect/token
// 2. [-] use the map to determine the groups the user needs to be assigned to
// 3. [-] get a list of groups the user is currently a member of
// GET https://keycloak.fp.sbtp.xyz/admin/realms/futureporn/users/fbec8417-e5e3-4282-a98d-ed2fbc4a7e82/groups
// GET https://keycloak.fp.sbtp.xyz/admin/realms/futureporn/users/fbec8417-e5e3-4282-a98d-ed2fbc4a7e82/role-mappings/realm
// 4. [ ] add any groups the user should be a member of
// PUT https://keycloak.fp.sbtp.xyz/admin/realms/futureporn/users/fbec8417-e5e3-4282-a98d-ed2fbc4a7e82/groups/ccaece3a-bb62-4fc9-be1d-6326ecdec65c
// 5. [ ] remove any groups the user should not be a member of
// DELETE https://keycloak.fp.sbtp.xyz/admin/realms/futureporn/users/fbec8417-e5e3-4282-a98d-ed2fbc4a7e82/groups/ccaece3a-bb62-4fc9-be1d-6326ecdec65c
//
const uma = await getUMAToken()
// console.log('uma token as follows')
// console.log(uma)
// console.log('got uma token')
const desiredKeycloakGroupIds = patreonTiersList
.map((entitledTierId) => getKeycloakIdFromTierId(entitledTierId))
.filter((groupId): groupId is string => groupId !== undefined);
// console.log(`got desiredKeycloakGroupIds=${desiredKeycloakGroupIds.join(', ')}`)
if (desiredKeycloakGroupIds.length === 0) {
throw new Error(`failed to get keycloak group id from the following patreon tiers: ${patreonTiersList.join(',')}`);
}
// console.log('desiredKeycloakGroupIds as follows')
// console.log(desiredKeycloakGroupIds)
const actualKeycloakGroupIds = await getUserGroups(uma, keycloakUserId);
// console.log(`actualKeyclaokGroupIds as follows`)
// console.log(actualKeycloakGroupIds)
const { toAdd, toRemove } = calculateSyncChanges(desiredKeycloakGroupIds, actualKeycloakGroupIds);
if (toAdd.length > 0) {
// console.log(`Groups to add: ${toAdd.join(', ')}`);
await Promise.all(toAdd.map(groupId => addUserGroup(uma, keycloakUserId, groupId)));
}
if (toRemove.length > 0) {
// console.log(`Groups to remove: ${toRemove.join(', ')}`);
await Promise.all(toRemove.map(groupId => removeUserGroup(uma, keycloakUserId, groupId)));
}
console.log('finished updating keycloak groups.')
}