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 = { 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 { 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 { 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 { 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 { 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 { 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.') }