196 lines
6.7 KiB
TypeScript
196 lines
6.7 KiB
TypeScript
|
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.')
|
||
|
|
||
|
|
||
|
}
|
||
|
|