feat(keycloak): use ESO

This commit is contained in:
Masaki Yatsu
2025-08-30 12:16:17 +09:00
parent e7ed3a1a67
commit 57c75689fd
3 changed files with 238 additions and 54 deletions

View File

@@ -7,7 +7,8 @@ export KEYCLOAK_HOST := env("KEYCLOAK_HOST", "")
export K8S_OIDC_CLIENT_ID := env('K8S_OIDC_CLIENT_ID', "k8s") export K8S_OIDC_CLIENT_ID := env('K8S_OIDC_CLIENT_ID', "k8s")
export KEYCLOAK_ADMIN_USER := env("KEYCLOAK_ADMIN_USER", "") export KEYCLOAK_ADMIN_USER := env("KEYCLOAK_ADMIN_USER", "")
export KEYCLOAK_ADMIN_PASSWORD := env("KEYCLOAK_ADMIN_PASSWORD", "") 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] [private]
default: default:
@@ -35,6 +36,21 @@ create-credentials:
password=$(just utils::random-password) password=$(just utils::random-password)
fi fi
just create-namespace just create-namespace
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 if kubectl get secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} &>/dev/null; then
kubectl delete --ignore-not-found secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} kubectl delete --ignore-not-found secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE}
fi fi
@@ -42,13 +58,15 @@ create-credentials:
--from-literal=admin-user="${admin_user}" \ --from-literal=admin-user="${admin_user}" \
--from-literal=password="${password}" --from-literal=password="${password}"
if [ "${VAULT_ENABLED}" != "false" ]; then if helm status vault -n ${K8S_VAULT_NAMESPACE} &>/dev/null; then
just put-admin-credentials-to-vault "${admin_user}" "${password}" just put-admin-credentials-to-vault "${admin_user}" "${password}"
fi fi
fi
# Delete Keycloak secret # Delete Keycloak secret
delete-credentials: delete-credentials:
@kubectl delete secret keycloak-credentials -n ${KEYCLOAK_NAMESPACE} --ignore-not-found @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 Keycloak database secret
create-database-secret: create-database-secret:
@@ -164,6 +182,24 @@ add-audience-mapper client_id:
export KEYCLOAK_CLIENT_ID={{ client_id }} export KEYCLOAK_CLIENT_ID={{ client_id }}
dotenvx run -f ../.env.local -- tsx ./scripts/add-audience-mapper.ts 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 Keycloak client groups mapper
add-groups-mapper client_id: add-groups-mapper client_id:
#!/bin/bash #!/bin/bash
@@ -300,36 +336,6 @@ delete-user username='':
done done
dotenvx run -f ../.env.local -- tsx ./scripts/delete-user.ts 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
put-admin-credentials-to-vault username password: put-admin-credentials-to-vault username password:
@just vault::put-root keycloak/admin username={{ username }} password={{ 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}" --user="https://${KEYCLOAK_HOST}/realms/${KEYCLOAK_REALM}#${USERNAME}"
# Check if user exists # Check if user exists
[no-exit-message]
user-exists username='': user-exists username='':
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
@@ -382,14 +389,6 @@ admin-username:
echo "${KEYCLOAK_ADMIN_USER}" echo "${KEYCLOAK_ADMIN_USER}"
exit 0 exit 0
fi 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 just default-admin-username
# Print Keycloak admin password # Print Keycloak admin password
@@ -400,14 +399,6 @@ admin-password:
echo "${KEYCLOAK_ADMIN_PASSWORD}" echo "${KEYCLOAK_ADMIN_PASSWORD}"
exit 0 exit 0
fi 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 just default-admin-password
# Print default Keycloak admin username # Print default Keycloak admin username

View File

@@ -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

View File

@@ -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();