{ 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 an external or manually provisioned database; has no effect when a local database is automatically provisioned. ''; }; 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='keycloak'" | grep -q 1 || $PSQL -tAc "CREATE ROLE keycloak 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 "keycloak"' ''; }; 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 'keycloak'@'localhost' IDENTIFIED BY '$db_password';" echo "CREATE DATABASE keycloak CHARACTER SET utf8 COLLATE utf8_unicode_ci;" echo "GRANT ALL PRIVILEGES ON keycloak.* TO 'keycloak'@'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; }