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

196 lines
6.7 KiB
TypeScript
Raw Normal View History

2024-12-12 07:23:46 +00:00
import { type Tier, type TiersList, tiers } from './patreon'
import { keycloakLocalUrl } from './constants'
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 getKeycloakIdFromTierId(tierId: string): string | undefined {
// Find the tier name corresponding to the provided tier ID
const tierName = Object.entries(tiers).find(([_, id]) => id === tierId)?.[0];
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(`${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[]> {
const res = await fetch(`${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=${data}, 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(`${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 deleteUserGroup(uma: UMAToken, userId: string, groupId: string): Promise<void> {
const res = await fetch(`${keycloakLocalUrl}/admin/realms/futureporn/users/${userId}/groups/${groupId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${uma.access_token}`
}
})
if (!res.ok) {
throw new Error(`failed to deleteUserGroup. res.status=${res.status}, res.statusText=${res.statusText}`);
}
}
function compareArrays<T>(desired: T[], actual: T[]) {
const missing = desired.filter(item => !actual.includes(item));
const extra = actual.filter(item => !desired.includes(item));
return { missing, extra };
}
export async function syncronizeKeycloakRoles(keycloakUserId: string, patreonTiersList: TiersList) {
// 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
//
console.log('uma token as follows')
const uma = await getUMAToken()
console.log(uma)
const desiredKeycloakGroupIds = patreonTiersList
.map((entitledTierId) => getKeycloakIdFromTierId(entitledTierId))
.filter((groupId): groupId is string => groupId !== undefined);
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 { missing, extra } = compareArrays(desiredKeycloakGroupIds, actualKeycloakGroupIds)
console.log(`missing=${missing.join(',')} extra=${extra.join(',')}`)
if (missing) {
console.log(`Adding the following keycloak groups ${missing.join(',')}`)
const addPromises = missing.map((groupId) => addUserGroup(uma, keycloakUserId, groupId))
await Promise.all(addPromises)
}
if (extra) {
console.log(`Removing the following keycloak groups ${extra.join(',')}`)
const removePromises = extra.map((groupId) => deleteUserGroup(uma, keycloakUserId, groupId))
await Promise.all(removePromises)
}
console.log('finished updating keycloak groups.')
}