about summary refs log tree commit diff
path: root/nixos/modules/services/web-apps/keycloak.nix
diff options
context:
space:
mode:
Diffstat (limited to 'nixos/modules/services/web-apps/keycloak.nix')
-rw-r--r--nixos/modules/services/web-apps/keycloak.nix875
1 files changed, 476 insertions, 399 deletions
diff --git a/nixos/modules/services/web-apps/keycloak.nix b/nixos/modules/services/web-apps/keycloak.nix
index e08f6dcabd2f5..a01f0049b2c72 100644
--- a/nixos/modules/services/web-apps/keycloak.nix
+++ b/nixos/modules/services/web-apps/keycloak.nix
@@ -3,280 +3,311 @@
 let
   cfg = config.services.keycloak;
   opt = options.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.
+  inherit (lib) types mkOption concatStringsSep mapAttrsToList
+    escapeShellArg recursiveUpdate optionalAttrs boolToString mkOrder
+    sort filterAttrs concatMapStringsSep concatStrings mkIf
+    optionalString optionals mkDefault literalExpression hasSuffix
+    foldl' isAttrs filter attrNames elem literalDocBook
+    maintainers;
 
-        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.
+  inherit (builtins) match typeOf;
+in
+{
+  options.services.keycloak =
+    let
+      inherit (types) bool str nullOr attrsOf path enum anything
+        package port;
+    in
+    {
+      enable = mkOption {
+        type = bool;
+        default = false;
+        example = true;
+        description = ''
+          Whether to enable the Keycloak identity and access management
+          server.
+        '';
+      };
 
-        A special syntax can be used to allow command line Java system
-        properties to override the value: ''${property.name:value}
-      '';
-    };
+      bindAddress = mkOption {
+        type = str;
+        default = "\${jboss.bind.address:0.0.0.0}";
+        example = "127.0.0.1";
+        description = ''
+          On which address Keycloak should accept new connections.
 
-    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}
+        '';
+      };
 
-        A special syntax can be used to allow command line Java system
-        properties to override the value: ''${property.name:value}
-      '';
-    };
+      httpPort = mkOption {
+        type = str;
+        default = "\${jboss.http.port:80}";
+        example = "8080";
+        description = ''
+          On which port Keycloak should listen for new HTTP connections.
 
-    frontendUrl = lib.mkOption {
-      type = lib.types.str;
-      apply = x: if lib.hasSuffix "/" x then x else x + "/";
-      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.
-      '';
-    };
+          A special syntax can be used to allow command line Java system
+          properties to override the value: ''${property.name:value}
+        '';
+      };
 
-    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.
-      '';
-    };
+      httpsPort = mkOption {
+        type = str;
+        default = "\${jboss.https.port:443}";
+        example = "8443";
+        description = ''
+          On which port Keycloak should listen for new HTTPS connections.
 
-    sslCertificate = lib.mkOption {
-      type = lib.types.nullOr lib.types.path;
-      default = null;
-      example = "/run/keys/ssl_cert";
-      description = ''
-        The path to a PEM formatted certificate to use for TLS/SSL
-        connections.
+          A special syntax can be used to allow command line Java system
+          properties to override the value: ''${property.name:value}
+        '';
+      };
 
-        This should be a string, not a Nix path, since Nix paths are
-        copied into the world-readable Nix store.
-      '';
-    };
+      frontendUrl = mkOption {
+        type = str;
+        apply = x:
+          if x == "" || hasSuffix "/" x then
+            x
+          else
+            x + "/";
+        example = "keycloak.example.com/auth";
+        description = ''
+          The public URL used as base for all frontend requests. Should
+          normally include a trailing <literal>/auth</literal>.
 
-    sslCertificateKey = lib.mkOption {
-      type = lib.types.nullOr lib.types.path;
-      default = null;
-      example = "/run/keys/ssl_key";
-      description = ''
-        The path to a PEM formatted private key to use for TLS/SSL
-        connections.
+          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.
+        '';
+      };
 
-        This should be a string, not a Nix path, since Nix paths are
-        copied into the world-readable Nix store.
-      '';
-    };
+      forceBackendUrlToFrontendUrl = mkOption {
+        type = 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.
+        '';
+      };
 
-    database = {
-      type = lib.mkOption {
-        type = lib.types.enum [ "mysql" "postgresql" ];
-        default = "postgresql";
-        example = "mysql";
+      sslCertificate = mkOption {
+        type = nullOr path;
+        default = null;
+        example = "/run/keys/ssl_cert";
         description = ''
-          The type of database Keycloak should connect to.
+          The path to a PEM formatted certificate to use for TLS/SSL
+          connections.
+
+          This should be a string, not a Nix path, since Nix paths are
+          copied into the world-readable Nix store.
         '';
       };
 
-      host = lib.mkOption {
-        type = lib.types.str;
-        default = "localhost";
+      sslCertificateKey = mkOption {
+        type = nullOr path;
+        default = null;
+        example = "/run/keys/ssl_key";
         description = ''
-          Hostname of the database to connect to.
+          The path to a PEM formatted private key to use for TLS/SSL
+          connections.
+
+          This should be a string, not a Nix path, since Nix paths are
+          copied into the world-readable Nix store.
         '';
       };
 
-      port =
-        let
-          dbPorts = {
-            postgresql = 5432;
-            mysql = 3306;
-          };
-        in
-          lib.mkOption {
-            type = lib.types.port;
+      database = {
+        type = mkOption {
+          type = enum [ "mysql" "postgresql" ];
+          default = "postgresql";
+          example = "mysql";
+          description = ''
+            The type of database Keycloak should connect to.
+          '';
+        };
+
+        host = mkOption {
+          type = str;
+          default = "localhost";
+          description = ''
+            Hostname of the database to connect to.
+          '';
+        };
+
+        port =
+          let
+            dbPorts = {
+              postgresql = 5432;
+              mysql = 3306;
+            };
+          in
+          mkOption {
+            type = port;
             default = dbPorts.${cfg.database.type};
-            defaultText = lib.literalDocBook "default port of selected database";
+            defaultText = literalDocBook "default port of selected database";
             description = ''
               Port of the database to connect to.
             '';
           };
 
-      useSSL = lib.mkOption {
-        type = lib.types.bool;
-        default = cfg.database.host != "localhost";
-        defaultText = lib.literalExpression ''config.${opt.database.host} != "localhost"'';
-        description = ''
-          Whether the database connection should be secured by SSL /
-          TLS.
-        '';
-      };
+        useSSL = mkOption {
+          type = bool;
+          default = cfg.database.host != "localhost";
+          defaultText = literalExpression ''config.${opt.database.host} != "localhost"'';
+          description = ''
+            Whether the database connection should be secured by SSL /
+            TLS.
+          '';
+        };
 
-      caCert = 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.
+        caCert = mkOption {
+          type = nullOr 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.
+            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.
-        '';
+            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.
+          '';
+        };
+
+        createLocally = mkOption {
+          type = 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.database.host is customized.
+          '';
+        };
+
+        username = mkOption {
+          type = 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.
+
+            To use this with a local database, set <xref
+            linkend="opt-services.keycloak.database.createLocally" /> to
+            <literal>false</literal> and create the database and user
+            manually. The database should be called
+            <literal>keycloak</literal>.
+          '';
+        };
+
+        passwordFile = mkOption {
+          type = 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.
+          '';
+        };
       };
 
-      createLocally = lib.mkOption {
-        type = lib.types.bool;
-        default = true;
+      package = mkOption {
+        type = package;
+        default = pkgs.keycloak;
+        defaultText = literalExpression "pkgs.keycloak";
         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.database.host is customized.
+          Keycloak package to use.
         '';
       };
 
-      username = lib.mkOption {
-        type = lib.types.str;
-        default = "keycloak";
+      initialAdminPassword = mkOption {
+        type = str;
+        default = "changeme";
         description = ''
-          Username to use when connecting to an external or manually
-          provisioned database; has no effect when a local database is
-          automatically provisioned.
-
-          To use this with a local database, set <xref
-          linkend="opt-services.keycloak.database.createLocally" /> to
-          <literal>false</literal> and create the database and user
-          manually. The database should be called
-          <literal>keycloak</literal>.
+          Initial password set for the <literal>admin</literal>
+          user. The password is not stored safely and should be changed
+          immediately in the admin panel.
         '';
       };
 
-      passwordFile = lib.mkOption {
-        type = lib.types.path;
-        example = "/run/keys/db_password";
+      themes = mkOption {
+        type = attrsOf package;
+        default = { };
         description = ''
-          File containing the database password.
+          Additional theme packages for Keycloak. Each theme is linked into
+          subdirectory with a corresponding attribute name.
 
-          This should be a string, not a Nix path, since Nix paths are
-          copied into the world-readable Nix store.
+          Theme packages consist of several subdirectories which provide
+          different theme types: for example, <literal>account</literal>,
+          <literal>login</literal> etc. After adding a theme to this option you
+          can select it by its name in Keycloak administration console.
         '';
       };
-    };
-
-    package = lib.mkOption {
-      type = lib.types.package;
-      default = pkgs.keycloak;
-      defaultText = lib.literalExpression "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.literalExpression ''
-        {
-          "subsystem=keycloak-server" = {
-            "spi=hostname" = {
-              "provider=default" = null;
-              "provider=fixed" = {
-                enabled = true;
-                properties.hostname = "keycloak.example.com";
+      extraConfig = mkOption {
+        type = attrsOf anything;
+        default = { };
+        example = literalExpression ''
+          {
+            "subsystem=keycloak-server" = {
+              "spi=hostname" = {
+                "provider=default" = null;
+                "provider=fixed" = {
+                  enabled = true;
+                  properties.hostname = "keycloak.example.com";
+                };
+                default-provider = "fixed";
               };
-              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>.
-      '';
-    };
+          }
+        '';
+        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
@@ -285,28 +316,58 @@ in
       createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.database.type == "postgresql";
       createLocalMySQL = databaseActuallyCreateLocally && cfg.database.type == "mysql";
 
-      mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" {} ''
+      mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" { } ''
         ${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.database.caCert} -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;
+      # Both theme and theme type directories need to be actual directories in one hierarchy to pass Keycloak checks.
+      themesBundle = pkgs.runCommand "keycloak-themes" { } ''
+        linkTheme() {
+          theme="$1"
+          name="$2"
+
+          mkdir "$out/$name"
+          for typeDir in "$theme"/*; do
+            if [ -d "$typeDir" ]; then
+              type="$(basename "$typeDir")"
+              mkdir "$out/$name/$type"
+              for file in "$typeDir"/*; do
+                ln -sn "$file" "$out/$name/$type/$(basename "$file")"
+              done
+            fi
+          done
+        }
+
+        mkdir -p "$out"
+        for theme in ${cfg.package}/themes/*; do
+          if [ -d "$theme" ]; then
+            linkTheme "$theme" "$(basename "$theme")"
+          fi
+        done
+
+        ${concatStringsSep "\n" (mapAttrsToList (name: theme: "linkTheme ${theme} ${escapeShellArg name}") cfg.themes)}
+      '';
+
+      keycloakConfig' = foldl' 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;
+              };
             };
+            "theme=defaults".dir = toString themesBundle;
           };
-        };
-        "subsystem=datasources"."data-source=KeycloakDS" = {
-          max-pool-size = "20";
-          user-name = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username;
-          password = "@db-password@";
-        };
-      } [
-        (lib.optionalAttrs (cfg.database.type == "postgresql") {
+          "subsystem=datasources"."data-source=KeycloakDS" = {
+            max-pool-size = "20";
+            user-name = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username;
+            password = "@db-password@";
+          };
+        } [
+        (optionalAttrs (cfg.database.type == "postgresql") {
           "subsystem=datasources" = {
             "jdbc-driver=postgresql" = {
               driver-module-name = "org.postgresql";
@@ -314,16 +375,16 @@ in
               driver-xa-datasource-class-name = "org.postgresql.xa.PGXADataSource";
             };
             "data-source=KeycloakDS" = {
-              connection-url = "jdbc:postgresql://${cfg.database.host}:${builtins.toString cfg.database.port}/keycloak";
+              connection-url = "jdbc:postgresql://${cfg.database.host}:${toString cfg.database.port}/keycloak";
               driver-name = "postgresql";
-              "connection-properties=ssl".value = lib.boolToString cfg.database.useSSL;
-            } // (lib.optionalAttrs (cfg.database.caCert != null) {
+              "connection-properties=ssl".value = boolToString cfg.database.useSSL;
+            } // (optionalAttrs (cfg.database.caCert != null) {
               "connection-properties=sslrootcert".value = cfg.database.caCert;
               "connection-properties=sslmode".value = "verify-ca";
             });
           };
         })
-        (lib.optionalAttrs (cfg.database.type == "mysql") {
+        (optionalAttrs (cfg.database.type == "mysql") {
           "subsystem=datasources" = {
             "jdbc-driver=mysql" = {
               driver-module-name = "com.mysql";
@@ -331,28 +392,40 @@ in
               driver-class-name = "com.mysql.jdbc.Driver";
             };
             "data-source=KeycloakDS" = {
-              connection-url = "jdbc:mysql://${cfg.database.host}:${builtins.toString cfg.database.port}/keycloak";
+              connection-url = "jdbc:mysql://${cfg.database.host}:${toString cfg.database.port}/keycloak";
               driver-name = "mysql";
-              "connection-properties=useSSL".value = lib.boolToString cfg.database.useSSL;
-              "connection-properties=requireSSL".value = lib.boolToString cfg.database.useSSL;
-              "connection-properties=verifyServerCertificate".value = lib.boolToString cfg.database.useSSL;
+              "connection-properties=useSSL".value = boolToString cfg.database.useSSL;
+              "connection-properties=requireSSL".value = boolToString cfg.database.useSSL;
+              "connection-properties=verifyServerCertificate".value = boolToString cfg.database.useSSL;
               "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.database.caCert != null) {
+            } // (optionalAttrs (cfg.database.caCert != null) {
               "connection-properties=trustCertificateKeyStoreUrl".value = "file:${mySqlCaKeystore}";
               "connection-properties=trustCertificateKeyStorePassword".value = "notsosecretpassword";
             });
           };
         })
-        (lib.optionalAttrs (cfg.sslCertificate != null && cfg.sslCertificateKey != null) {
+        (optionalAttrs (cfg.sslCertificate != null && cfg.sslCertificateKey != 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=elytron" = mkOrder 900 {
+            "key-store=httpsKS" = mkOrder 900 {
+              path = "/run/keycloak/ssl/certificate_private_key_bundle.p12";
+              credential-reference.clear-text = "notsosecretpassword";
+              type = "JKS";
+            };
+            "key-manager=httpsKM" = mkOrder 901 {
+              key-store = "httpsKS";
+              credential-reference.clear-text = "notsosecretpassword";
+            };
+            "server-ssl-context=httpsSSC" = mkOrder 902 {
+              key-manager = "httpsKM";
+            };
+          };
+          "subsystem=undertow" = mkOrder 901 {
+            "server=default-server"."https-listener=https".ssl-context = "httpsSSC";
           };
-          "subsystem=undertow"."server=default-server"."https-listener=https".security-realm = "UndertowRealm";
         })
         cfg.extraConfig
       ];
@@ -441,41 +514,42 @@ in
               # with `expression` to evaluate.
               prefixExpression = string:
                 let
-                  match = (builtins.match ''"\$\{.*}"'' string);
+                  matchResult = match ''"\$\{.*}"'' string;
                 in
-                  if match != null then
-                    "expression " + string
-                  else
-                    string;
+                if matchResult != null then
+                  "expression " + string
+                else
+                  string;
 
               writeAttribute = attribute: value:
                 let
-                  type = builtins.typeOf value;
+                  type = 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}")
+                if type == "set" then
+                  let
+                    names = attrNames value;
+                  in
+                  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 elem type [ "string" "path" "bool" ] then
+                  let
+                    value' = if type == "bool" then 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 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}'!";
+                else throw "Unsupported type '${type}' for path '${path}'!";
             in
-              lib.concatStrings
-                (lib.mapAttrsToList
-                  (attribute: value: (writeAttribute attribute value))
-                  set);
+            concatStrings
+              (mapAttrsToList
+                (attribute: value: (writeAttribute attribute value))
+                set);
 
 
           /* Produces an argument list for the JBoss `add()` function,
@@ -498,98 +572,108 @@ in
             let
               makeArg = attribute: value:
                 let
-                  type = builtins.typeOf value;
+                  type = 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}'!";
+                if type == "set" then
+                  "${attribute} = { " + (makeArgList value) + " }"
+                else if elem type [ "string" "path" "bool" ] then
+                  "${attribute} = ${if type == "bool" then boolToString value else ''"${value}"''}"
+                else if value == null then
+                  ""
+                else
+                  throw "Unsupported type '${type}' for attribute '${attribute}'!";
+
             in
-              lib.concatStringsSep ", " (lib.mapAttrsToList makeArg set);
+            concatStringsSep ", " (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
+          /* Recurses into the `nodeValue` attrset. 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:
+          recurse = nodePath: nodeValue:
             let
-              path = state.path ++ (lib.optional (node != null) node);
+              nodeContent =
+                if isAttrs nodeValue && nodeValue._type or "" == "order" then
+                  nodeValue.content
+                else
+                  nodeValue;
               isPath = name:
                 let
-                  value = lib.getAttrFromPath (path ++ [ name ]) attrs;
+                  value = nodeContent.${name};
                 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 '='!"
+                if (match ".*([=]).*" name) == [ "=" ] then
+                  if isAttrs value || value == null then
+                    true
                   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 ''
+                    throw "Parsing path '${concatStringsSep "." (nodePath ++ [ name ])}' failed: JBoss attributes cannot contain '='!"
+                else
+                  false;
+              jbossPath = "/" + concatStringsSep "/" nodePath;
+              children = if !isAttrs nodeContent then { } else nodeContent;
+              subPaths = filter isPath (attrNames children);
+              getPriority = name:
+                let
+                  value = children.${name};
+                in
+                if value._type or "" == "order" then value.priority else 1000;
+              orderedSubPaths = sort (a: b: getPriority a < getPriority b) subPaths;
+              jbossAttrs = filterAttrs (name: _: !(isPath name)) children;
+              text =
+                if nodeContent != null then
+                  ''
                     if (outcome != success) of ${jbossPath}:read-resource()
                         ${jbossPath}:add(${makeArgList jbossAttrs})
                     end-if
-                  '' + (writeAttributes jbossPath jbossAttrs)
-                  else ''
+                  '' + writeAttributes jbossPath jbossAttrs
+                else
+                  ''
                     if (outcome == success) of ${jbossPath}:read-resource()
                         ${jbossPath}:remove()
                     end-if
-                  '') + (builtins.foldl' recurse { text = ""; inherit path; } subPaths).text;
-              };
+                  '';
+            in
+            text + concatMapStringsSep "\n" (name: recurse (nodePath ++ [ name ]) children.${name}) orderedSubPaths;
         in
-          (recurse { text = ""; path = []; } null).text;
-
+        recurse [ ] attrs;
 
       jbossCliScript = pkgs.writeText "jboss-cli-script" (mkJbossScript keycloakConfig');
 
-      keycloakConfig = pkgs.runCommand "keycloak-config" {
-        nativeBuildInputs = [ cfg.package ];
-      } ''
-        export JBOSS_BASE_DIR="$(pwd -P)";
-        export JBOSS_MODULEPATH="${cfg.package}/modules";
-        export JBOSS_LOG_DIR="$JBOSS_BASE_DIR/log";
+      keycloakConfig = pkgs.runCommand "keycloak-config"
+        {
+          nativeBuildInputs = [ cfg.package ];
+        }
+        ''
+          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
+          cp -r ${cfg.package}/standalone/configuration .
+          chmod -R u+rwX ./configuration
 
-        mkdir -p {deployments,ssl}
+          mkdir -p {deployments,ssl}
 
-        standalone.sh&
+          standalone.sh&
 
-        attempt=1
-        max_attempts=30
-        while ! 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
+          attempt=1
+          max_attempts=30
+          while ! 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
 
-        jboss-cli.sh --connect --file=${jbossCliScript} --echo-command
+          jboss-cli.sh --connect --file=${jbossCliScript} --echo-command
 
-        cp configuration/standalone.xml $out
-      '';
+          cp configuration/standalone.xml $out
+        '';
     in
-      lib.mkIf cfg.enable {
-
+    mkIf cfg.enable
+      {
         assertions = [
           {
             assertion = (cfg.database.useSSL && cfg.database.type == "postgresql") -> (cfg.database.caCert != null);
@@ -599,7 +683,7 @@ in
 
         environment.systemPackages = [ cfg.package ];
 
-        systemd.services.keycloakPostgreSQLInit = lib.mkIf createLocalPostgreSQL {
+        systemd.services.keycloakPostgreSQLInit = mkIf createLocalPostgreSQL {
           after = [ "postgresql.service" ];
           before = [ "keycloak.service" ];
           bindsTo = [ "postgresql.service" ];
@@ -623,7 +707,7 @@ in
           '';
         };
 
-        systemd.services.keycloakMySQLInit = lib.mkIf createLocalMySQL {
+        systemd.services.keycloakMySQLInit = mkIf createLocalMySQL {
           after = [ "mysql.service" ];
           before = [ "keycloak.service" ];
           bindsTo = [ "mysql.service" ];
@@ -650,13 +734,16 @@ in
           let
             databaseServices =
               if createLocalPostgreSQL then [
-                "keycloakPostgreSQLInit.service" "postgresql.service"
+                "keycloakPostgreSQLInit.service"
+                "postgresql.service"
               ]
               else if createLocalMySQL then [
-                "keycloakMySQLInit.service" "mysql.service"
+                "keycloakMySQLInit.service"
+                "mysql.service"
               ]
               else [ ];
-          in {
+          in
+          {
             after = databaseServices;
             bindsTo = databaseServices;
             wantedBy = [ "multi-user.target" ];
@@ -671,52 +758,16 @@ in
               JBOSS_MODULEPATH = "${cfg.package}/modules";
             };
             serviceConfig = {
-              ExecStartPre = let
-                startPreFullPrivileges = ''
-                  set -o errexit -o pipefail -o nounset -o errtrace
-                  shopt -s inherit_errexit
-
-                  umask u=rwx,g=,o=
-
-                  install -T -m 0400 -o keycloak -g keycloak '${cfg.database.passwordFile}' /run/keycloak/secrets/db_password
-                '' + lib.optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
-                  install -T -m 0400 -o keycloak -g keycloak '${cfg.sslCertificate}' /run/keycloak/secrets/ssl_cert
-                  install -T -m 0400 -o keycloak -g keycloak '${cfg.sslCertificateKey}' /run/keycloak/secrets/ssl_key
-                '';
-                startPre = ''
-                  set -o errexit -o pipefail -o nounset -o errtrace
-                  shopt -s inherit_errexit
-
-                  umask u=rwx,g=,o=
-
-                  install -m 0600 ${cfg.package}/standalone/configuration/*.properties /run/keycloak/configuration
-                  install -T -m 0600 ${keycloakConfig} /run/keycloak/configuration/standalone.xml
-
-                  replace-secret '@db-password@' '/run/keycloak/secrets/db_password' /run/keycloak/configuration/standalone.xml
-
-                  export JAVA_OPTS=-Djboss.server.config.user.dir=/run/keycloak/configuration
-                  add-user-keycloak.sh -u admin -p '${cfg.initialAdminPassword}'
-                '' + lib.optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
-                  pushd /run/keycloak/ssl/
-                  cat /run/keycloak/secrets/ssl_cert <(echo) \
-                      /run/keycloak/secrets/ssl_key <(echo) \
-                      /etc/ssl/certs/ca-certificates.crt \
-                      > allcerts.pem
-                  openssl pkcs12 -export -in /run/keycloak/secrets/ssl_cert -inkey /run/keycloak/secrets/ssl_key -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}"
+              LoadCredential = [
+                "db_password:${cfg.database.passwordFile}"
+              ] ++ optionals (cfg.sslCertificate != null && cfg.sslCertificateKey != null) [
+                "ssl_cert:${cfg.sslCertificate}"
+                "ssl_key:${cfg.sslCertificateKey}"
               ];
-              ExecStart = "${cfg.package}/bin/standalone.sh";
               User = "keycloak";
               Group = "keycloak";
               DynamicUser = true;
               RuntimeDirectory = map (p: "keycloak/" + p) [
-                "secrets"
                 "configuration"
                 "deployments"
                 "data"
@@ -728,13 +779,39 @@ in
               LogsDirectory = "keycloak";
               AmbientCapabilities = "CAP_NET_BIND_SERVICE";
             };
+            script = ''
+              set -o errexit -o pipefail -o nounset -o errtrace
+              shopt -s inherit_errexit
+
+              umask u=rwx,g=,o=
+
+              install -m 0600 ${cfg.package}/standalone/configuration/*.properties /run/keycloak/configuration
+              install -T -m 0600 ${keycloakConfig} /run/keycloak/configuration/standalone.xml
+
+              replace-secret '@db-password@' "$CREDENTIALS_DIRECTORY/db_password" /run/keycloak/configuration/standalone.xml
+
+              export JAVA_OPTS=-Djboss.server.config.user.dir=/run/keycloak/configuration
+              add-user-keycloak.sh -u admin -p '${cfg.initialAdminPassword}'
+            '' + optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
+              pushd /run/keycloak/ssl/
+              cat "$CREDENTIALS_DIRECTORY/ssl_cert" <(echo) \
+                  "$CREDENTIALS_DIRECTORY/ssl_key" <(echo) \
+                  /etc/ssl/certs/ca-certificates.crt \
+                  > allcerts.pem
+              openssl pkcs12 -export -in "$CREDENTIALS_DIRECTORY/ssl_cert" -inkey "$CREDENTIALS_DIRECTORY/ssl_key" -chain \
+                             -name "${cfg.frontendUrl}" -out certificate_private_key_bundle.p12 \
+                             -CAfile allcerts.pem -passout pass:notsosecretpassword
+              popd
+            '' + ''
+              ${cfg.package}/bin/standalone.sh
+            '';
           };
 
-        services.postgresql.enable = lib.mkDefault createLocalPostgreSQL;
-        services.mysql.enable = lib.mkDefault createLocalMySQL;
-        services.mysql.package = lib.mkIf createLocalMySQL pkgs.mariadb;
+        services.postgresql.enable = mkDefault createLocalPostgreSQL;
+        services.mysql.enable = mkDefault createLocalMySQL;
+        services.mysql.package = mkIf createLocalMySQL pkgs.mariadb;
       };
 
   meta.doc = ./keycloak.xml;
-  meta.maintainers = [ lib.maintainers.talyz ];
+  meta.maintainers = [ maintainers.talyz ];
 }