diff --git a/nixos/doc/manual/release-notes/rl-2103.xml b/nixos/doc/manual/release-notes/rl-2103.xml
index 988d7f27d1d..2d0c5e39f7a 100644
--- a/nixos/doc/manual/release-notes/rl-2103.xml
+++ b/nixos/doc/manual/release-notes/rl-2103.xml
@@ -39,7 +39,19 @@
-
+
+ Keycloak,
+ an open source identity and access management server with
+ support for OpenID Connect,
+ OAUTH 2.0 and
+ SAML
+ 2.0.
+
+
+ See the Keycloak
+ section of the NixOS manual for more information.
+
diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 8557420afaa..3fd7ebd1ca7 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -863,6 +863,7 @@
./services/web-apps/ihatemoney
./services/web-apps/jirafeau.nix
./services/web-apps/jitsi-meet.nix
+ ./services/web-apps/keycloak.nix
./services/web-apps/limesurvey.nix
./services/web-apps/mattermost.nix
./services/web-apps/mediawiki.nix
diff --git a/nixos/modules/services/web-apps/keycloak.nix b/nixos/modules/services/web-apps/keycloak.nix
new file mode 100644
index 00000000000..bbb0c8d0483
--- /dev/null
+++ b/nixos/modules/services/web-apps/keycloak.nix
@@ -0,0 +1,692 @@
+{ 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 /auth.
+
+ See the
+ Hostname section of the Keycloak server installation
+ manual 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 . 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 the
+ Hostname section of the Keycloak server installation
+ manual 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 null, 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 admin
+ 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
+ standalone.xml.
+
+ 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
+ null.
+
+ The example configuration does the equivalent of the following
+ script, which removes the hostname provider
+ default, adds the deprecated hostname
+ provider fixed and defines it the default:
+
+
+ /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")
+
+
+ You can discover available options by using the jboss-cli.sh
+ program and by referring to the Keycloak
+ Server Installation and Configuration Guide.
+ '';
+ };
+
+ };
+
+ 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="$( 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;
+}
diff --git a/nixos/modules/services/web-apps/keycloak.xml b/nixos/modules/services/web-apps/keycloak.xml
new file mode 100644
index 00000000000..ca5e223eee4
--- /dev/null
+++ b/nixos/modules/services/web-apps/keycloak.xml
@@ -0,0 +1,205 @@
+
+ Keycloak
+
+ Keycloak is an
+ open source identity and access management server with support for
+ OpenID
+ Connect, OAUTH
+ 2.0 and SAML
+ 2.0.
+
+
+ Administration
+
+ An administrative user with the username
+ admin is automatically created in the
+ master realm. Its initial password can be
+ configured by setting
+ and defaults to changeme. The password is
+ not stored safely and should be changed immediately in the
+ admin panel.
+
+
+
+ Refer to the Admin
+ Console section of the Keycloak Server Administration Guide for
+ information on how to administer your
+ Keycloak instance.
+
+
+
+
+ Database access
+
+ Keycloak can be used with either
+ PostgreSQL or
+ MySQL. Which one is used can be
+ configured in . The selected
+ database will automatically be enabled and a database and role
+ created unless is changed from
+ its default of localhost or is set
+ to false.
+
+
+
+ External database access can also be configured by setting
+ , , and as
+ appropriate. Note that you need to manually create a database
+ called keycloak and allow the configured
+ database user full access to it.
+
+
+
+
+ must be set to the path to a file containing the password used
+ to log in to the database. If
+ and
+ are kept at their defaults, the database role
+ keycloak with that password is provisioned
+ on the local database instance.
+
+
+
+
+ The path should be provided as a string, not a Nix path, since Nix
+ paths are copied into the world readable Nix store.
+
+
+
+
+
+ Frontend URL
+
+ The frontend URL is used as base for all frontend requests and
+ must be configured through .
+ It should normally include a trailing /auth
+ (the default web context).
+
+
+
+
+ determines whether Keycloak should force all requests to go
+ through the frontend URL. 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 the Hostname
+ section of the Keycloak Server Installation and Configuration
+ Guide for more information.
+
+
+
+
+ Setting up TLS/SSL
+
+ By default, Keycloak won't accept
+ unsecured HTTP connections originating from outside its local
+ network.
+
+
+
+ For HTTPS support, a TLS certificate and private key is
+ required. They should be PEM
+ formatted and concatenated into a single file. The path
+ to this file should be configured in
+ .
+
+
+
+
+ The path should be provided as a string, not a Nix path,
+ since Nix paths are copied into the world readable Nix store.
+
+
+
+
+
+ Additional configuration
+
+ Additional Keycloak configuration options, for which no
+ explicit NixOS options are provided,
+ can be set in .
+
+
+
+ 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
+ null.
+
+
+ For example, the following script, which removes the hostname
+ provider default, adds the deprecated
+ hostname provider fixed and defines it the
+ default:
+
+
+/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")
+
+
+ would be expressed as
+
+
+services.keycloak.extraConfig = {
+ "subsystem=keycloak-server" = {
+ "spi=hostname" = {
+ "provider=default" = null;
+ "provider=fixed" = {
+ enabled = true;
+ properties.hostname = "keycloak.example.com";
+ };
+ default-provider = "fixed";
+ };
+ };
+};
+
+
+
+ You can discover available options by using the jboss-cli.sh
+ program and by referring to the Keycloak
+ Server Installation and Configuration Guide.
+
+
+
+
+ Example configuration
+
+ A basic configuration with some custom settings could look like this:
+
+services.keycloak = {
+ enable = true;
+ initialAdminPassword = "e6Wcm0RrtegMEHl"; # change on first login
+ frontendUrl = "https://keycloak.example.com/auth";
+ forceBackendUrlToFrontendUrl = true;
+ certificatePrivateKeyBundle = "/run/keys/ssl_cert";
+ databasePasswordFile = "/run/keys/db_password";
+};
+
+
+
+
+
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 4e4d8b5e689..553f4f8fc4c 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -175,6 +175,7 @@ in
kernel-latest = handleTest ./kernel-latest.nix {};
kernel-lts = handleTest ./kernel-lts.nix {};
kernel-testing = handleTest ./kernel-testing.nix {};
+ keycloak = discoverTests (import ./keycloak.nix);
keymap = handleTest ./keymap.nix {};
knot = handleTest ./knot.nix {};
krb5 = discoverTests (import ./krb5 {});
diff --git a/nixos/tests/keycloak.nix b/nixos/tests/keycloak.nix
new file mode 100644
index 00000000000..f448a0f7095
--- /dev/null
+++ b/nixos/tests/keycloak.nix
@@ -0,0 +1,144 @@
+# This tests Keycloak: it starts the service, creates a realm with an
+# OIDC client and a user, and simulates the user logging in to the
+# client using their Keycloak login.
+
+let
+ frontendUrl = "http://keycloak/auth";
+ initialAdminPassword = "h4IhoJFnt2iQIR9";
+
+ keycloakTest = import ./make-test-python.nix (
+ { pkgs, databaseType, ... }:
+ {
+ name = "keycloak";
+ meta = with pkgs.stdenv.lib.maintainers; {
+ maintainers = [ talyz ];
+ };
+
+ nodes = {
+ keycloak = { ... }: {
+ virtualisation.memorySize = 1024;
+ services.keycloak = {
+ enable = true;
+ inherit frontendUrl databaseType initialAdminPassword;
+ databasePasswordFile = pkgs.writeText "dbPassword" "wzf6vOCbPp6cqTH";
+ };
+ environment.systemPackages = with pkgs; [
+ xmlstarlet
+ libtidy
+ jq
+ ];
+ };
+ };
+
+ testScript =
+ let
+ client = {
+ clientId = "test-client";
+ name = "test-client";
+ redirectUris = [ "urn:ietf:wg:oauth:2.0:oob" ];
+ };
+
+ user = {
+ firstName = "Chuck";
+ lastName = "Testa";
+ username = "chuck.testa";
+ email = "chuck.testa@example.com";
+ };
+
+ password = "password1234";
+
+ realm = {
+ enabled = true;
+ realm = "test-realm";
+ clients = [ client ];
+ users = [(
+ user // {
+ enabled = true;
+ credentials = [{
+ type = "password";
+ temporary = false;
+ value = password;
+ }];
+ }
+ )];
+ };
+
+ realmDataJson = pkgs.writeText "realm-data.json" (builtins.toJSON realm);
+
+ jqCheckUserinfo = pkgs.writeText "check-userinfo.jq" ''
+ if {
+ "firstName": .given_name,
+ "lastName": .family_name,
+ "username": .preferred_username,
+ "email": .email
+ } != ${builtins.toJSON user} then
+ error("Wrong user info!")
+ else
+ empty
+ end
+ '';
+ in ''
+ keycloak.start()
+ keycloak.wait_for_unit("keycloak.service")
+ keycloak.wait_until_succeeds("curl -sSf ${frontendUrl}")
+
+
+ ### Realm Setup ###
+
+ # Get an admin interface access token
+ keycloak.succeed(
+ "curl -sSf -d 'client_id=admin-cli' -d 'username=admin' -d 'password=${initialAdminPassword}' -d 'grant_type=password' '${frontendUrl}/realms/master/protocol/openid-connect/token' | jq -r '\"Authorization: bearer \" + .access_token' >admin_auth_header"
+ )
+
+ # Publish the realm, including a test OIDC client and user
+ keycloak.succeed(
+ "curl -sSf -H @admin_auth_header -X POST -H 'Content-Type: application/json' -d @${realmDataJson} '${frontendUrl}/admin/realms/'"
+ )
+
+ # Generate and save the client secret. To do this we need
+ # Keycloak's internal id for the client.
+ keycloak.succeed(
+ "curl -sSf -H @admin_auth_header '${frontendUrl}/admin/realms/${realm.realm}/clients?clientId=${client.name}' | jq -r '.[].id' >client_id",
+ "curl -sSf -H @admin_auth_header -X POST '${frontendUrl}/admin/realms/${realm.realm}/clients/'$(client_secret",
+ )
+
+
+ ### Authentication Testing ###
+
+ # Start the login process by sending an initial request to the
+ # OIDC authentication endpoint, saving the returned page. Tidy
+ # up the HTML (XmlStarlet is picky) and extract the login form
+ # post url.
+ keycloak.succeed(
+ "curl -sSf -c cookie '${frontendUrl}/realms/${realm.realm}/protocol/openid-connect/auth?client_id=${client.name}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&scope=openid+email&response_type=code&response_mode=query&nonce=qw4o89g3qqm' >login_form",
+ "tidy -q -m login_form || true",
+ "xml sel -T -t -m \"_:html/_:body/_:div/_:div/_:div/_:div/_:div/_:div/_:form[@id='kc-form-login']\" -v @action login_form >form_post_url",
+ )
+
+ # Post the login form and save the response. Once again tidy up
+ # the HTML, then extract the authorization code.
+ keycloak.succeed(
+ "curl -sSf -L -b cookie -d 'username=${user.username}' -d 'password=${password}' -d 'credentialId=' \"$(auth_code_html",
+ "tidy -q -m auth_code_html || true",
+ "xml sel -T -t -m \"_:html/_:body/_:div/_:div/_:div/_:div/_:div/_:input[@id='code']\" -v @value auth_code_html >auth_code",
+ )
+
+ # Exchange the authorization code for an access token.
+ keycloak.succeed(
+ "curl -sSf -d grant_type=authorization_code -d code=$(auth_header"
+ )
+
+ # Use the access token on the OIDC userinfo endpoint and check
+ # that the returned user info matches what we initialized the
+ # realm with.
+ keycloak.succeed(
+ "curl -sSf -H @auth_header '${frontendUrl}/realms/${realm.realm}/protocol/openid-connect/userinfo' | jq -f ${jqCheckUserinfo}"
+ )
+ '';
+ }
+ );
+in
+{
+ postgres = keycloakTest { databaseType = "postgresql"; };
+ mysql = keycloakTest { databaseType = "mysql"; };
+}
diff --git a/pkgs/servers/keycloak/default.nix b/pkgs/servers/keycloak/default.nix
index 614eb2a4679..67d3d9bd45a 100644
--- a/pkgs/servers/keycloak/default.nix
+++ b/pkgs/servers/keycloak/default.nix
@@ -1,5 +1,21 @@
-{ stdenv, fetchzip, makeWrapper, jre }:
+{ stdenv, lib, fetchzip, makeWrapper, jre, writeText, nixosTests
+, postgresql_jdbc ? null, mysql_jdbc ? null
+}:
+let
+ mkModuleXml = name: jarFile: writeText "module.xml" ''
+
+
+
+
+
+
+
+
+
+
+ '';
+in
stdenv.mkDerivation rec {
pname = "keycloak";
version = "11.0.2";
@@ -16,20 +32,37 @@ stdenv.mkDerivation rec {
cp -r * $out
rm -rf $out/bin/*.{ps1,bat}
- rm -rf $out/bin/add-user-keycloak.sh
- rm -rf $out/bin/jconsole.sh
- chmod +x $out/bin/standalone.sh
- wrapProgram $out/bin/standalone.sh \
- --prefix PATH ":" ${jre}/bin ;
+ module_path=$out/modules/system/layers/keycloak
+ if ! [[ -d $module_path ]]; then
+ echo "The module path $module_path not found!"
+ exit 1
+ fi
+
+ ${lib.optionalString (postgresql_jdbc != null) ''
+ mkdir -p $module_path/org/postgresql/main
+ ln -s ${postgresql_jdbc}/share/java/postgresql-jdbc.jar $module_path/org/postgresql/main/
+ ln -s ${mkModuleXml "org.postgresql" "postgresql-jdbc.jar"} $module_path/org/postgresql/main/module.xml
+ ''}
+ ${lib.optionalString (mysql_jdbc != null) ''
+ mkdir -p $module_path/com/mysql/main
+ ln -s ${mysql_jdbc}/share/java/mysql-connector-java.jar $module_path/com/mysql/main/
+ ln -s ${mkModuleXml "com.mysql" "mysql-connector-java.jar"} $module_path/com/mysql/main/module.xml
+ ''}
+
+ wrapProgram $out/bin/standalone.sh --set JAVA_HOME ${jre}
+ wrapProgram $out/bin/add-user-keycloak.sh --set JAVA_HOME ${jre}
+ wrapProgram $out/bin/jboss-cli.sh --set JAVA_HOME ${jre}
'';
+ passthru.tests = nixosTests.keycloak;
+
meta = with stdenv.lib; {
homepage = "https://www.keycloak.org/";
description = "Identity and access management for modern applications and services";
license = licenses.asl20;
platforms = jre.meta.platforms;
- maintainers = [ maintainers.ngerstle ];
+ maintainers = with maintainers; [ ngerstle talyz ];
};
}