diff --git a/keycloak/justfile b/keycloak/justfile index 8b199db..74cfde5 100644 --- a/keycloak/justfile +++ b/keycloak/justfile @@ -7,7 +7,8 @@ export KEYCLOAK_HOST := env("KEYCLOAK_HOST", "") export K8S_OIDC_CLIENT_ID := env('K8S_OIDC_CLIENT_ID', "k8s") export KEYCLOAK_ADMIN_USER := env("KEYCLOAK_ADMIN_USER", "") export KEYCLOAK_ADMIN_PASSWORD := env("KEYCLOAK_ADMIN_PASSWORD", "") -export VAULT_ENABLED := env("VAULT_ENABLED", "true") +export K8S_VAULT_NAMESPACE := env("K8S_VAULT_NAMESPACE", "vault") +export EXTERNAL_SECRETS_NAMESPACE := env("EXTERNAL_SECRETS_NAMESPACE", "external-secrets") [private] default: @@ -35,20 +36,37 @@ create-credentials: password=$(just utils::random-password) fi just create-namespace - if kubectl get secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} &>/dev/null; then - kubectl delete --ignore-not-found secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} - fi - kubectl create secret generic keycloak-credentials -n ${KEYCLOAK_NAMESPACE} \ - --from-literal=admin-user="${admin_user}" \ - --from-literal=password="${password}" - if [ "${VAULT_ENABLED}" != "false" ]; then + if helm status external-secrets -n ${EXTERNAL_SECRETS_NAMESPACE} &>/dev/null; then + echo "External Secrets Operator detected. Creating ExternalSecret..." just put-admin-credentials-to-vault "${admin_user}" "${password}" + + kubectl delete secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} --ignore-not-found + kubectl delete externalsecret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} --ignore-not-found + + gomplate -f keycloak-credentials-external-secret.gomplate.yaml | kubectl apply -f - + + echo "Waiting for ExternalSecret to sync..." + kubectl wait --for=condition=Ready externalsecret/keycloak-credentials \ + -n ${KEYCLOAK_NAMESPACE} --timeout=60s + else + echo "External Secrets Operator not found. Creating secret directly..." + if kubectl get secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} &>/dev/null; then + kubectl delete --ignore-not-found secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} + fi + kubectl create secret generic keycloak-credentials -n ${KEYCLOAK_NAMESPACE} \ + --from-literal=admin-user="${admin_user}" \ + --from-literal=password="${password}" + + if helm status vault -n ${K8S_VAULT_NAMESPACE} &>/dev/null; then + just put-admin-credentials-to-vault "${admin_user}" "${password}" + fi fi # Delete Keycloak secret delete-credentials: @kubectl delete secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} --ignore-not-found + @kubectl delete externalsecret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} --ignore-not-found # Create Keycloak database secret create-database-secret: @@ -164,6 +182,24 @@ add-audience-mapper client_id: export KEYCLOAK_CLIENT_ID={{ client_id }} dotenvx run -f ../.env.local -- tsx ./scripts/add-audience-mapper.ts +# Add attribute mapper for Keycloak client +add-attribute-mapper client_id attribute_name display_name='' claim_name='' options='' default_value='' mapper_name='' view_perms='admin,user' edit_perms='admin': + #!/bin/bash + set -euo pipefail + export KEYCLOAK_ADMIN_USER=$(just keycloak::admin-user) + export KEYCLOAK_ADMIN_PASSWORD=$(just keycloak::admin-password) + export KEYCLOAK_REALM=${KEYCLOAK_REALM} + export CLIENT_ID={{ client_id }} + export ATTRIBUTE_NAME={{ attribute_name }} + export ATTRIBUTE_DISPLAY_NAME="{{ display_name }}" + export ATTRIBUTE_CLAIM_NAME="{{ claim_name }}" + export ATTRIBUTE_OPTIONS="{{ options }}" + export ATTRIBUTE_DEFAULT_VALUE="{{ default_value }}" + export MAPPER_NAME="{{ mapper_name }}" + export ATTRIBUTE_VIEW_PERMISSIONS="{{ view_perms }}" + export ATTRIBUTE_EDIT_PERMISSIONS="{{ edit_perms }}" + dotenvx run -f ../.env.local -- tsx ./scripts/add-attribute-mapper.ts + # Add Keycloak client groups mapper add-groups-mapper client_id: #!/bin/bash @@ -300,36 +336,6 @@ delete-user username='': done dotenvx run -f ../.env.local -- tsx ./scripts/delete-user.ts -# Create an admin user -# create-admin-user username='' password='': -# #!/bin/bash -# set -euo pipefail -# echo "Creating a new admin user in Keycloak" -# export KEYCLOAK_ADMIN_USER=$(just admin-user) -# export KEYCLOAK_ADMIN_PASSWORD=$(just admin-password) -# export USERNAME="{{ username }}" -# export PASSWORD="{{ password }}" -# while [ -z "${USERNAME}" ]; do -# USERNAME=$(gum input --prompt="Admin username: " --width=100) -# done -# if [ -z "${PASSWORD}" ]; then -# PASSWORD=$( -# gum input --prompt="Admin assword: " --password --width=100 \ -# --placeholder="Empty to generate a random password" -# ) -# fi -# if [ -z "${PASSWORD}" ]; then -# PASSWORD=$(just utils::random-password) -# fi -# export EMAIL="" -# export FIRST_NAME="" -# export LAST_NAME="" -# export CREATE_AS_ADMIN=true -# dotenvx run -f ../.env.local -- tsx ./scripts/create-user.ts -# if [ "${VAULT_ENABLED}" != "false" ]; then -# just put-admin-credentials-to-vault "${USERNAME}" "${PASSWORD}" -# fi - # Put admin credentials to Vault put-admin-credentials-to-vault username password: @just vault::put-root keycloak/admin username={{ username }} password={{ password }} @@ -363,6 +369,7 @@ create-system-user username='' password='': --user="https://${KEYCLOAK_HOST}/realms/${KEYCLOAK_REALM}#${USERNAME}" # Check if user exists +[no-exit-message] user-exists username='': #!/bin/bash set -euo pipefail @@ -382,14 +389,6 @@ admin-username: echo "${KEYCLOAK_ADMIN_USER}" exit 0 fi - # if [ "${VAULT_ENABLED}" != "false" ]; then - # just vault::setup-token - # if just vault::exist keycloak/admin 2>/dev/null; then - # just vault::get-root keycloak/admin username - # echo - # exit 0 - # fi - # fi just default-admin-username # Print Keycloak admin password @@ -400,14 +399,6 @@ admin-password: echo "${KEYCLOAK_ADMIN_PASSWORD}" exit 0 fi - # if [ "${VAULT_ENABLED}" != "false" ]; then - # just vault::setup-token - # if just vault::exist keycloak/admin 2>/dev/null; then - # just vault::get-root keycloak/admin password - # echo - # exit 0 - # fi - # fi just default-admin-password # Print default Keycloak admin username diff --git a/keycloak/keycloak-credentials-external-secret.gomplate.yaml b/keycloak/keycloak-credentials-external-secret.gomplate.yaml new file mode 100644 index 0000000..d250777 --- /dev/null +++ b/keycloak/keycloak-credentials-external-secret.gomplate.yaml @@ -0,0 +1,22 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: keycloak-credentials + namespace: {{ .Env.KEYCLOAK_NAMESPACE }} +spec: + refreshInterval: 1h + secretStoreRef: + name: vault-secret-store + kind: ClusterSecretStore + target: + name: keycloak-credentials + creationPolicy: Owner + data: + - secretKey: admin-user + remoteRef: + key: keycloak/admin + property: username + - secretKey: password + remoteRef: + key: keycloak/admin + property: password \ No newline at end of file diff --git a/keycloak/scripts/add-attribute-mapper.ts b/keycloak/scripts/add-attribute-mapper.ts new file mode 100644 index 0000000..fd8ee42 --- /dev/null +++ b/keycloak/scripts/add-attribute-mapper.ts @@ -0,0 +1,171 @@ +import KcAdminClient from "@keycloak/keycloak-admin-client"; +import invariant from "tiny-invariant"; + +const main = async () => { + const keycloakHost = process.env.KEYCLOAK_HOST; + invariant(keycloakHost, "KEYCLOAK_HOST environment variable is required."); + + const adminUsername = process.env.KEYCLOAK_ADMIN_USER; + invariant(adminUsername, "KEYCLOAK_ADMIN_USER environment variable is required."); + + const adminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD; + invariant(adminPassword, "KEYCLOAK_ADMIN_PASSWORD environment variable is required"); + + const realmName = process.env.KEYCLOAK_REALM; + invariant(realmName, "KEYCLOAK_REALM environment variable is required"); + + const clientId = process.env.CLIENT_ID; + invariant(clientId, "CLIENT_ID environment variable is required"); + + const attributeName = process.env.ATTRIBUTE_NAME; + invariant(attributeName, "ATTRIBUTE_NAME environment variable is required"); + + const attributeDisplayName = process.env.ATTRIBUTE_DISPLAY_NAME || attributeName; + const attributeClaimName = process.env.ATTRIBUTE_CLAIM_NAME || attributeName; + const attributeOptions = process.env.ATTRIBUTE_OPTIONS?.split(","); + const attributeDefaultValue = process.env.ATTRIBUTE_DEFAULT_VALUE; + const mapperName = process.env.MAPPER_NAME || `${attributeDisplayName} Mapper`; + + // Parse permissions from environment variables + const viewPermissions = process.env.ATTRIBUTE_VIEW_PERMISSIONS?.split(",") || ["admin", "user"]; + const editPermissions = process.env.ATTRIBUTE_EDIT_PERMISSIONS?.split(",") || ["admin"]; + + const includeInUserInfo = process.env.INCLUDE_IN_USERINFO !== "false"; + const includeInIdToken = process.env.INCLUDE_IN_ID_TOKEN !== "false"; + const includeInAccessToken = process.env.INCLUDE_IN_ACCESS_TOKEN !== "false"; + + console.log(`Setting ${attributeName} attribute`); + if (attributeDefaultValue) { + console.log(`Default value: ${attributeDefaultValue}`); + } + if (attributeOptions) { + console.log(`Valid options: ${attributeOptions.join(", ")}`); + } + console.log(`View permissions: ${viewPermissions.join(", ")}`); + console.log(`Edit permissions: ${editPermissions.join(", ")}`); + + const kcAdminClient = new KcAdminClient({ + baseUrl: `https://${keycloakHost}`, + realmName: "master", + }); + + try { + await kcAdminClient.auth({ + username: adminUsername, + password: adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + console.log("Authentication successful."); + + // Set realm to work with + kcAdminClient.setConfig({ + realmName, + }); + + // Get current User Profile configuration + const userProfile = await kcAdminClient.users.getProfile(); + + // Check if attribute already exists + const existingAttribute = userProfile.attributes?.find( + (attr: any) => attr.name === attributeName + ); + + if (existingAttribute) { + console.log(`${attributeName} attribute already exists in User Profile.`); + } else { + // Add attribute to User Profile with proper permissions + if (!userProfile.attributes) { + userProfile.attributes = []; + } + + const attributeConfig: any = { + name: attributeName, + displayName: attributeDisplayName, + permissions: { + view: viewPermissions, + edit: editPermissions, + }, + }; + + // Add validations if options are provided + if (attributeOptions && attributeOptions.length > 0) { + attributeConfig.validations = { + options: { options: attributeOptions }, + }; + } + + userProfile.attributes.push(attributeConfig); + + // Update User Profile + await kcAdminClient.users.updateProfile(userProfile); + console.log( + `${attributeName} attribute added to User Profile successfully with admin edit permissions.` + ); + } + + // Create protocol mapper for the attribute if it doesn't exist + const client = await kcAdminClient.clients.find({ clientId }); + if (client.length === 0) { + console.error(`Client '${clientId}' not found.`); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + const clientInternalId = client[0].id; + invariant(clientInternalId, "Client internal ID is required"); + + // Check if the mapper already exists + const mappers = await kcAdminClient.clients.listProtocolMappers({ id: clientInternalId }); + const existingMapper = mappers.find((mapper) => mapper.name === mapperName); + + if (existingMapper) { + console.log(`${mapperName} already exists.`); + } else { + // Create the protocol mapper + await kcAdminClient.clients.addProtocolMapper( + { id: clientInternalId }, + { + name: mapperName, + protocol: "openid-connect", + protocolMapper: "oidc-usermodel-attribute-mapper", + config: { + "userinfo.token.claim": includeInUserInfo.toString(), + "id.token.claim": includeInIdToken.toString(), + "access.token.claim": includeInAccessToken.toString(), + "claim.name": attributeClaimName, + "jsonType.label": "String", + "user.attribute": attributeName, + multivalued: "false", + }, + } + ); + console.log(`${mapperName} created successfully.`); + } + + // Set default value for all existing users if specified + if (attributeDefaultValue) { + const users = await kcAdminClient.users.find(); + for (const user of users) { + if (!user.attributes?.[attributeName]) { + await kcAdminClient.users.update( + { id: user.id! }, + { + ...user, + attributes: { + ...user.attributes, + [attributeName]: [attributeDefaultValue], + }, + } + ); + console.log(`Set default ${attributeName} for user ${user.username}`); + } + } + } + } catch (error) { + console.error("An error occurred:", error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +}; + +main();