depot/third_party/nixpkgs/nixos/modules/services/web-apps/keycloak.nix

694 lines
28 KiB
Nix
Raw Normal View History

{ config, pkgs, lib, ... }:
let
cfg = config.services.keycloak;
in
{
options.services.keycloak = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Whether to enable the Keycloak identity and access management
server.
'';
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "\${jboss.bind.address:0.0.0.0}";
example = "127.0.0.1";
description = ''
On which address Keycloak should accept new connections.
A special syntax can be used to allow command line Java system
properties to override the value: ''${property.name:value}
'';
};
httpPort = lib.mkOption {
type = lib.types.str;
default = "\${jboss.http.port:80}";
example = "8080";
description = ''
On which port Keycloak should listen for new HTTP connections.
A special syntax can be used to allow command line Java system
properties to override the value: ''${property.name:value}
'';
};
httpsPort = lib.mkOption {
type = lib.types.str;
default = "\${jboss.https.port:443}";
example = "8443";
description = ''
On which port Keycloak should listen for new HTTPS connections.
A special syntax can be used to allow command line Java system
properties to override the value: ''${property.name:value}
'';
};
frontendUrl = lib.mkOption {
type = lib.types.str;
example = "keycloak.example.com/auth";
description = ''
The public URL used as base for all frontend requests. Should
normally include a trailing <literal>/auth</literal>.
See <link xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the
Hostname section of the Keycloak server installation
manual</link> for more information.
'';
};
forceBackendUrlToFrontendUrl = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Whether Keycloak should force all requests to go through the
frontend URL configured in <xref
linkend="opt-services.keycloak.frontendUrl" />. By default,
Keycloak allows backend requests to instead use its local
hostname or IP address and may also advertise it to clients
through its OpenID Connect Discovery endpoint.
See <link
xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the
Hostname section of the Keycloak server installation
manual</link> for more information.
'';
};
certificatePrivateKeyBundle = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/keys/ssl_cert";
description = ''
The path to a PEM formatted bundle of the private key and
certificate to use for TLS connections.
This should be a string, not a Nix path, since Nix paths are
copied into the world-readable Nix store.
'';
};
databaseType = lib.mkOption {
type = lib.types.enum [ "mysql" "postgresql" ];
default = "postgresql";
example = "mysql";
description = ''
The type of database Keycloak should connect to.
'';
};
databaseHost = lib.mkOption {
type = lib.types.str;
default = "localhost";
description = ''
Hostname of the database to connect to.
'';
};
databasePort =
let
dbPorts = {
postgresql = 5432;
mysql = 3306;
};
in
lib.mkOption {
type = lib.types.port;
default = dbPorts.${cfg.databaseType};
description = ''
Port of the database to connect to.
'';
};
databaseUseSSL = lib.mkOption {
type = lib.types.bool;
default = cfg.databaseHost != "localhost";
description = ''
Whether the database connection should be secured by SSL /
TLS.
'';
};
databaseCaCert = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The SSL / TLS CA certificate that verifies the identity of the
database server.
Required when PostgreSQL is used and SSL is turned on.
For MySQL, if left at <literal>null</literal>, the default
Java keystore is used, which should suffice if the server
certificate is issued by an official CA.
'';
};
databaseCreateLocally = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether a database should be automatically created on the
local host. Set this to false if you plan on provisioning a
local database yourself. This has no effect if
services.keycloak.databaseHost is customized.
'';
};
databaseUsername = lib.mkOption {
type = lib.types.str;
default = "keycloak";
description = ''
Username to use when connecting to the database.
This is also used for automatic provisioning of the database.
Changing this after the initial installation doesn't delete the
old user and can cause further problems.
'';
};
databasePasswordFile = lib.mkOption {
type = lib.types.path;
example = "/run/keys/db_password";
description = ''
File containing the database password.
This should be a string, not a Nix path, since Nix paths are
copied into the world-readable Nix store.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.keycloak;
description = ''
Keycloak package to use.
'';
};
initialAdminPassword = lib.mkOption {
type = lib.types.str;
default = "changeme";
description = ''
Initial password set for the <literal>admin</literal>
user. The password is not stored safely and should be changed
immediately in the admin panel.
'';
};
extraConfig = lib.mkOption {
type = lib.types.attrs;
default = { };
example = lib.literalExample ''
{
"subsystem=keycloak-server" = {
"spi=hostname" = {
"provider=default" = null;
"provider=fixed" = {
enabled = true;
properties.hostname = "keycloak.example.com";
};
default-provider = "fixed";
};
};
}
'';
description = ''
Additional Keycloak configuration options to set in
<literal>standalone.xml</literal>.
Options are expressed as a Nix attribute set which matches the
structure of the jboss-cli configuration. The configuration is
effectively overlayed on top of the default configuration
shipped with Keycloak. To remove existing nodes and undefine
attributes from the default configuration, set them to
<literal>null</literal>.
The example configuration does the equivalent of the following
script, which removes the hostname provider
<literal>default</literal>, adds the deprecated hostname
provider <literal>fixed</literal> and defines it the default:
<programlisting>
/subsystem=keycloak-server/spi=hostname/provider=default:remove()
/subsystem=keycloak-server/spi=hostname/provider=fixed:add(enabled = true, properties = { hostname = "keycloak.example.com" })
/subsystem=keycloak-server/spi=hostname:write-attribute(name=default-provider, value="fixed")
</programlisting>
You can discover available options by using the <link
xlink:href="http://docs.wildfly.org/21/Admin_Guide.html#Command_Line_Interface">jboss-cli.sh</link>
program and by referring to the <link
xlink:href="https://www.keycloak.org/docs/latest/server_installation/index.html">Keycloak
Server Installation and Configuration Guide</link>.
'';
};
};
config =
let
# We only want to create a database if we're actually going to connect to it.
databaseActuallyCreateLocally = cfg.databaseCreateLocally && cfg.databaseHost == "localhost";
createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.databaseType == "postgresql";
createLocalMySQL = databaseActuallyCreateLocally && cfg.databaseType == "mysql";
mySqlCaKeystore = pkgs.runCommandNoCC "mysql-ca-keystore" {} ''
${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.databaseCaCert} -keystore $out -storepass notsosecretpassword -noprompt
'';
keycloakConfig' = builtins.foldl' lib.recursiveUpdate {
"interface=public".inet-address = cfg.bindAddress;
"socket-binding-group=standard-sockets"."socket-binding=http".port = cfg.httpPort;
"subsystem=keycloak-server"."spi=hostname" = {
"provider=default" = {
enabled = true;
properties = {
inherit (cfg) frontendUrl forceBackendUrlToFrontendUrl;
};
};
};
"subsystem=datasources"."data-source=KeycloakDS" = {
max-pool-size = "20";
user-name = if databaseActuallyCreateLocally then "keycloak" else cfg.databaseUsername;
password = "@db-password@";
};
} [
(lib.optionalAttrs (cfg.databaseType == "postgresql") {
"subsystem=datasources" = {
"jdbc-driver=postgresql" = {
driver-module-name = "org.postgresql";
driver-name = "postgresql";
driver-xa-datasource-class-name = "org.postgresql.xa.PGXADataSource";
};
"data-source=KeycloakDS" = {
connection-url = "jdbc:postgresql://${cfg.databaseHost}:${builtins.toString cfg.databasePort}/keycloak";
driver-name = "postgresql";
"connection-properties=ssl".value = lib.boolToString cfg.databaseUseSSL;
} // (lib.optionalAttrs (cfg.databaseCaCert != null) {
"connection-properties=sslrootcert".value = cfg.databaseCaCert;
"connection-properties=sslmode".value = "verify-ca";
});
};
})
(lib.optionalAttrs (cfg.databaseType == "mysql") {
"subsystem=datasources" = {
"jdbc-driver=mysql" = {
driver-module-name = "com.mysql";
driver-name = "mysql";
driver-class-name = "com.mysql.jdbc.Driver";
};
"data-source=KeycloakDS" = {
connection-url = "jdbc:mysql://${cfg.databaseHost}:${builtins.toString cfg.databasePort}/keycloak";
driver-name = "mysql";
"connection-properties=useSSL".value = lib.boolToString cfg.databaseUseSSL;
"connection-properties=requireSSL".value = lib.boolToString cfg.databaseUseSSL;
"connection-properties=verifyServerCertificate".value = lib.boolToString cfg.databaseUseSSL;
"connection-properties=characterEncoding".value = "UTF-8";
valid-connection-checker-class-name = "org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLValidConnectionChecker";
validate-on-match = true;
exception-sorter-class-name = "org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLExceptionSorter";
} // (lib.optionalAttrs (cfg.databaseCaCert != null) {
"connection-properties=trustCertificateKeyStoreUrl".value = "file:${mySqlCaKeystore}";
"connection-properties=trustCertificateKeyStorePassword".value = "notsosecretpassword";
});
};
})
(lib.optionalAttrs (cfg.certificatePrivateKeyBundle != null) {
"socket-binding-group=standard-sockets"."socket-binding=https".port = cfg.httpsPort;
"core-service=management"."security-realm=UndertowRealm"."server-identity=ssl" = {
keystore-path = "/run/keycloak/ssl/certificate_private_key_bundle.p12";
keystore-password = "notsosecretpassword";
};
"subsystem=undertow"."server=default-server"."https-listener=https".security-realm = "UndertowRealm";
})
cfg.extraConfig
];
/* Produces a JBoss CLI script that creates paths and sets
attributes matching those described by `attrs`. When the
script is run, the existing settings are effectively overlayed
by those from `attrs`. Existing attributes can be unset by
defining them `null`.
JBoss paths and attributes / maps are distinguished by their
name, where paths follow a `key=value` scheme.
Example:
mkJbossScript {
"subsystem=keycloak-server"."spi=hostname" = {
"provider=fixed" = null;
"provider=default" = {
enabled = true;
properties = {
inherit frontendUrl;
forceBackendUrlToFrontendUrl = false;
};
};
};
}
=> ''
if (outcome != success) of /:read-resource()
/:add()
end-if
if (outcome != success) of /subsystem=keycloak-server:read-resource()
/subsystem=keycloak-server:add()
end-if
if (outcome != success) of /subsystem=keycloak-server/spi=hostname:read-resource()
/subsystem=keycloak-server/spi=hostname:add()
end-if
if (outcome != success) of /subsystem=keycloak-server/spi=hostname/provider=default:read-resource()
/subsystem=keycloak-server/spi=hostname/provider=default:add(enabled = true, properties = { forceBackendUrlToFrontendUrl = false, frontendUrl = "https://keycloak.example.com/auth" })
end-if
if (result != true) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="enabled")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=enabled, value=true)
end-if
if (result != false) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.forceBackendUrlToFrontendUrl")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.forceBackendUrlToFrontendUrl, value=false)
end-if
if (result != "https://keycloak.example.com/auth") of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.frontendUrl")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.frontendUrl, value="https://keycloak.example.com/auth")
end-if
if (outcome != success) of /subsystem=keycloak-server/spi=hostname/provider=fixed:read-resource()
/subsystem=keycloak-server/spi=hostname/provider=fixed:remove()
end-if
''
*/
mkJbossScript = attrs:
let
/* From a JBoss path and an attrset, produces a JBoss CLI
snippet that writes the corresponding attributes starting
at `path`. Recurses down into subattrsets as necessary,
producing the variable name from its full path in the
attrset.
Example:
writeAttributes "/subsystem=keycloak-server/spi=hostname/provider=default" {
enabled = true;
properties = {
forceBackendUrlToFrontendUrl = false;
frontendUrl = "https://keycloak.example.com/auth";
};
}
=> ''
if (result != true) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="enabled")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=enabled, value=true)
end-if
if (result != false) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.forceBackendUrlToFrontendUrl")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.forceBackendUrlToFrontendUrl, value=false)
end-if
if (result != "https://keycloak.example.com/auth") of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.frontendUrl")
/subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.frontendUrl, value="https://keycloak.example.com/auth")
end-if
''
*/
writeAttributes = path: set:
let
# JBoss expressions like `${var}` need to be prefixed
# with `expression` to evaluate.
prefixExpression = string:
let
match = (builtins.match ''"\$\{.*}"'' string);
in
if match != null then
"expression " + string
else
string;
writeAttribute = attribute: value:
let
type = builtins.typeOf value;
in
if type == "set" then
let
names = builtins.attrNames value;
in
builtins.foldl' (text: name: text + (writeAttribute "${attribute}.${name}" value.${name})) "" names
else if value == null then ''
if (outcome == success) of ${path}:read-attribute(name="${attribute}")
${path}:undefine-attribute(name="${attribute}")
end-if
''
else if builtins.elem type [ "string" "path" "bool" ] then
let
value' = if type == "bool" then lib.boolToString value else ''"${value}"'';
in ''
if (result != ${prefixExpression value'}) of ${path}:read-attribute(name="${attribute}")
${path}:write-attribute(name=${attribute}, value=${value'})
end-if
''
else throw "Unsupported type '${type}' for path '${path}'!";
in
lib.concatStrings
(lib.mapAttrsToList
(attribute: value: (writeAttribute attribute value))
set);
/* Produces an argument list for the JBoss `add()` function,
which adds a JBoss path and takes as its arguments the
required subpaths and attributes.
Example:
makeArgList {
enabled = true;
properties = {
forceBackendUrlToFrontendUrl = false;
frontendUrl = "https://keycloak.example.com/auth";
};
}
=> ''
enabled = true, properties = { forceBackendUrlToFrontendUrl = false, frontendUrl = "https://keycloak.example.com/auth" }
''
*/
makeArgList = set:
let
makeArg = attribute: value:
let
type = builtins.typeOf value;
in
if type == "set" then
"${attribute} = { " + (makeArgList value) + " }"
else if builtins.elem type [ "string" "path" "bool" ] then
"${attribute} = ${if type == "bool" then lib.boolToString value else ''"${value}"''}"
else if value == null then
""
else
throw "Unsupported type '${type}' for attribute '${attribute}'!";
in
lib.concatStringsSep ", " (lib.mapAttrsToList makeArg set);
/* Recurses into the `attrs` attrset, beginning at the path
resolved from `state.path ++ node`; if `node` is `null`,
starts from `state.path`. Only subattrsets that are JBoss
paths, i.e. follows the `key=value` format, are recursed
into - the rest are considered JBoss attributes / maps.
*/
recurse = state: node:
let
path = state.path ++ (lib.optional (node != null) node);
isPath = name:
let
value = lib.getAttrFromPath (path ++ [ name ]) attrs;
in
if (builtins.match ".*([=]).*" name) == [ "=" ] then
if builtins.isAttrs value || value == null then
true
else
throw "Parsing path '${lib.concatStringsSep "." (path ++ [ name ])}' failed: JBoss attributes cannot contain '='!"
else
false;
jbossPath = "/" + (lib.concatStringsSep "/" path);
nodeValue = lib.getAttrFromPath path attrs;
children = if !builtins.isAttrs nodeValue then {} else nodeValue;
subPaths = builtins.filter isPath (builtins.attrNames children);
jbossAttrs = lib.filterAttrs (name: _: !(isPath name)) children;
in
state // {
text = state.text + (
if nodeValue != null then ''
if (outcome != success) of ${jbossPath}:read-resource()
${jbossPath}:add(${makeArgList jbossAttrs})
end-if
'' + (writeAttributes jbossPath jbossAttrs)
else ''
if (outcome == success) of ${jbossPath}:read-resource()
${jbossPath}:remove()
end-if
'') + (builtins.foldl' recurse { text = ""; inherit path; } subPaths).text;
};
in
(recurse { text = ""; path = []; } null).text;
jbossCliScript = pkgs.writeText "jboss-cli-script" (mkJbossScript keycloakConfig');
keycloakConfig = pkgs.runCommandNoCC "keycloak-config" {} ''
export JBOSS_BASE_DIR="$(pwd -P)";
export JBOSS_MODULEPATH="${cfg.package}/modules";
export JBOSS_LOG_DIR="$JBOSS_BASE_DIR/log";
cp -r ${cfg.package}/standalone/configuration .
chmod -R u+rwX ./configuration
mkdir -p {deployments,ssl}
"${cfg.package}/bin/standalone.sh"&
attempt=1
max_attempts=30
while ! ${cfg.package}/bin/jboss-cli.sh --connect ':read-attribute(name=server-state)'; do
if [[ "$attempt" == "$max_attempts" ]]; then
echo "ERROR: Could not connect to Keycloak after $attempt attempts! Failing.." >&2
exit 1
fi
echo "Keycloak not fully started yet, retrying.. ($attempt/$max_attempts)"
sleep 1
(( attempt++ ))
done
${cfg.package}/bin/jboss-cli.sh --connect --file=${jbossCliScript} --echo-command
cp configuration/standalone.xml $out
'';
in
lib.mkIf cfg.enable {
assertions = [
{
assertion = (cfg.databaseUseSSL && cfg.databaseType == "postgresql") -> (cfg.databaseCaCert != null);
message = "A CA certificate must be specified (in 'services.keycloak.databaseCaCert') when PostgreSQL is used with SSL";
}
];
environment.systemPackages = [ cfg.package ];
systemd.services.keycloakPostgreSQLInit = lib.mkIf createLocalPostgreSQL {
after = [ "postgresql.service" ];
before = [ "keycloak.service" ];
bindsTo = [ "postgresql.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "postgres";
Group = "postgres";
};
script = ''
set -eu
PSQL=${config.services.postgresql.package}/bin/psql
db_password="$(<'${cfg.databasePasswordFile}')"
$PSQL -tAc "SELECT 1 FROM pg_roles WHERE rolname='${cfg.databaseUsername}'" | grep -q 1 || $PSQL -tAc "CREATE ROLE ${cfg.databaseUsername} WITH LOGIN PASSWORD '$db_password' CREATEDB"
$PSQL -tAc "SELECT 1 FROM pg_database WHERE datname = 'keycloak'" | grep -q 1 || $PSQL -tAc 'CREATE DATABASE "keycloak" OWNER "${cfg.databaseUsername}"'
'';
};
systemd.services.keycloakMySQLInit = lib.mkIf createLocalMySQL {
after = [ "mysql.service" ];
before = [ "keycloak.service" ];
bindsTo = [ "mysql.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = config.services.mysql.user;
Group = config.services.mysql.group;
};
script = ''
set -eu
db_password="$(<'${cfg.databasePasswordFile}')"
( echo "CREATE USER IF NOT EXISTS '${cfg.databaseUsername}'@'localhost' IDENTIFIED BY '$db_password';"
echo "CREATE DATABASE keycloak CHARACTER SET utf8 COLLATE utf8_unicode_ci;"
echo "GRANT ALL PRIVILEGES ON keycloak.* TO '${cfg.databaseUsername}'@'localhost';"
) | ${config.services.mysql.package}/bin/mysql -N
'';
};
systemd.services.keycloak =
let
databaseServices =
if createLocalPostgreSQL then [
"keycloakPostgreSQLInit.service" "postgresql.service"
]
else if createLocalMySQL then [
"keycloakMySQLInit.service" "mysql.service"
]
else [ ];
in {
after = databaseServices;
bindsTo = databaseServices;
wantedBy = [ "multi-user.target" ];
environment = {
JBOSS_LOG_DIR = "/var/log/keycloak";
JBOSS_BASE_DIR = "/run/keycloak";
JBOSS_MODULEPATH = "${cfg.package}/modules";
};
serviceConfig = {
ExecStartPre = let
startPreFullPrivileges = ''
set -eu
install -T -m 0400 -o keycloak -g keycloak '${cfg.databasePasswordFile}' /run/keycloak/secrets/db_password
'' + lib.optionalString (cfg.certificatePrivateKeyBundle != null) ''
install -T -m 0400 -o keycloak -g keycloak '${cfg.certificatePrivateKeyBundle}' /run/keycloak/secrets/ssl_cert_pk_bundle
'';
startPre = ''
set -eu
install -m 0600 ${cfg.package}/standalone/configuration/*.properties /run/keycloak/configuration
install -T -m 0600 ${keycloakConfig} /run/keycloak/configuration/standalone.xml
db_password="$(</run/keycloak/secrets/db_password)"
${pkgs.replace}/bin/replace-literal -fe '@db-password@' "$db_password" /run/keycloak/configuration/standalone.xml
export JAVA_OPTS=-Djboss.server.config.user.dir=/run/keycloak/configuration
${cfg.package}/bin/add-user-keycloak.sh -u admin -p '${cfg.initialAdminPassword}'
'' + lib.optionalString (cfg.certificatePrivateKeyBundle != null) ''
pushd /run/keycloak/ssl/
cat /run/keycloak/secrets/ssl_cert_pk_bundle <(echo) /etc/ssl/certs/ca-certificates.crt > allcerts.pem
${pkgs.openssl}/bin/openssl pkcs12 -export -in /run/keycloak/secrets/ssl_cert_pk_bundle -chain \
-name "${cfg.frontendUrl}" -out certificate_private_key_bundle.p12 \
-CAfile allcerts.pem -passout pass:notsosecretpassword
popd
'';
in [
"+${pkgs.writeShellScript "keycloak-start-pre-full-privileges" startPreFullPrivileges}"
"${pkgs.writeShellScript "keycloak-start-pre" startPre}"
];
ExecStart = "${cfg.package}/bin/standalone.sh";
User = "keycloak";
Group = "keycloak";
DynamicUser = true;
RuntimeDirectory = map (p: "keycloak/" + p) [
"secrets"
"configuration"
"deployments"
"data"
"ssl"
"log"
"tmp"
];
RuntimeDirectoryMode = 0700;
LogsDirectory = "keycloak";
AmbientCapabilities = "CAP_NET_BIND_SERVICE";
};
};
services.postgresql.enable = lib.mkDefault createLocalPostgreSQL;
services.mysql.enable = lib.mkDefault createLocalMySQL;
services.mysql.package = lib.mkIf createLocalMySQL pkgs.mysql;
};
meta.doc = ./keycloak.xml;
}