grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / web-apps / keycloak.nix
blob5d429675bafcf9a37580c5e5026309026ee2513a
1 { config, options, pkgs, lib, ... }:
3 let
4   cfg = config.services.keycloak;
5   opt = options.services.keycloak;
7   inherit (lib)
8     types
9     mkMerge
10     mkOption
11     mkChangedOptionModule
12     mkRenamedOptionModule
13     mkRemovedOptionModule
14     mkPackageOption
15     concatStringsSep
16     mapAttrsToList
17     escapeShellArg
18     mkIf
19     optionalString
20     optionals
21     mkDefault
22     literalExpression
23     isAttrs
24     literalMD
25     maintainers
26     catAttrs
27     collect
28     hasPrefix
29     ;
31   inherit (builtins)
32     elem
33     typeOf
34     isInt
35     isString
36     hashString
37     isPath
38     ;
40   prefixUnlessEmpty = prefix: string: optionalString (string != "") "${prefix}${string}";
43   imports =
44     [
45       (mkRenamedOptionModule
46         [ "services" "keycloak" "bindAddress" ]
47         [ "services" "keycloak" "settings" "http-host" ])
48       (mkRenamedOptionModule
49         [ "services" "keycloak" "forceBackendUrlToFrontendUrl"]
50         [ "services" "keycloak" "settings" "hostname-strict-backchannel"])
51       (mkChangedOptionModule
52         [ "services" "keycloak" "httpPort" ]
53         [ "services" "keycloak" "settings" "http-port" ]
54         (config:
55           builtins.fromJSON config.services.keycloak.httpPort))
56       (mkChangedOptionModule
57         [ "services" "keycloak" "httpsPort" ]
58         [ "services" "keycloak" "settings" "https-port" ]
59         (config:
60           builtins.fromJSON config.services.keycloak.httpsPort))
61       (mkRemovedOptionModule
62         [ "services" "keycloak" "frontendUrl" ]
63         ''
64           Set `services.keycloak.settings.hostname' and `services.keycloak.settings.http-relative-path' instead.
65           NOTE: You likely want to set 'http-relative-path' to '/auth' to keep compatibility with your clients.
66                 See its description for more information.
67         '')
68       (mkRemovedOptionModule
69         [ "services" "keycloak" "extraConfig" ]
70         "Use `services.keycloak.settings' instead.")
71     ];
73   options.services.keycloak =
74     let
75       inherit (types)
76         bool
77         str
78         int
79         nullOr
80         attrsOf
81         oneOf
82         path
83         enum
84         package
85         port;
87       assertStringPath = optionName: value:
88         if isPath value then
89           throw ''
90             services.keycloak.${optionName}:
91               ${toString value}
92               is a Nix path, but should be a string, since Nix
93               paths are copied into the world-readable Nix store.
94           ''
95         else value;
96     in
97     {
98       enable = mkOption {
99         type = bool;
100         default = false;
101         example = true;
102         description = ''
103           Whether to enable the Keycloak identity and access management
104           server.
105         '';
106       };
108       sslCertificate = mkOption {
109         type = nullOr path;
110         default = null;
111         example = "/run/keys/ssl_cert";
112         apply = assertStringPath "sslCertificate";
113         description = ''
114           The path to a PEM formatted certificate to use for TLS/SSL
115           connections.
116         '';
117       };
119       sslCertificateKey = mkOption {
120         type = nullOr path;
121         default = null;
122         example = "/run/keys/ssl_key";
123         apply = assertStringPath "sslCertificateKey";
124         description = ''
125           The path to a PEM formatted private key to use for TLS/SSL
126           connections.
127         '';
128       };
130       plugins = lib.mkOption {
131         type = lib.types.listOf lib.types.path;
132         default = [ ];
133         description = ''
134           Keycloak plugin jar, ear files or derivations containing
135           them. Packaged plugins are available through
136           `pkgs.keycloak.plugins`.
137         '';
138       };
140       database = {
141         type = mkOption {
142           type = enum [ "mysql" "mariadb" "postgresql" ];
143           default = "postgresql";
144           example = "mariadb";
145           description = ''
146             The type of database Keycloak should connect to.
147           '';
148         };
150         host = mkOption {
151           type = str;
152           default = "localhost";
153           description = ''
154             Hostname of the database to connect to.
155           '';
156         };
158         port =
159           let
160             dbPorts = {
161               postgresql = 5432;
162               mariadb = 3306;
163               mysql = 3306;
164             };
165           in
166           mkOption {
167             type = port;
168             default = dbPorts.${cfg.database.type};
169             defaultText = literalMD "default port of selected database";
170             description = ''
171               Port of the database to connect to.
172             '';
173           };
175         useSSL = mkOption {
176           type = bool;
177           default = cfg.database.host != "localhost";
178           defaultText = literalExpression ''config.${opt.database.host} != "localhost"'';
179           description = ''
180             Whether the database connection should be secured by SSL /
181             TLS.
182           '';
183         };
185         caCert = mkOption {
186           type = nullOr path;
187           default = null;
188           description = ''
189             The SSL / TLS CA certificate that verifies the identity of the
190             database server.
192             Required when PostgreSQL is used and SSL is turned on.
194             For MySQL, if left at `null`, the default
195             Java keystore is used, which should suffice if the server
196             certificate is issued by an official CA.
197           '';
198         };
200         createLocally = mkOption {
201           type = bool;
202           default = true;
203           description = ''
204             Whether a database should be automatically created on the
205             local host. Set this to false if you plan on provisioning a
206             local database yourself. This has no effect if
207             services.keycloak.database.host is customized.
208           '';
209         };
211         name = mkOption {
212           type = str;
213           default = "keycloak";
214           description = ''
215             Database name to use when connecting to an external or
216             manually provisioned database; has no effect when a local
217             database is automatically provisioned.
219             To use this with a local database, set [](#opt-services.keycloak.database.createLocally) to
220             `false` and create the database and user
221             manually.
222           '';
223         };
225         username = mkOption {
226           type = str;
227           default = "keycloak";
228           description = ''
229             Username to use when connecting to an external or manually
230             provisioned database; has no effect when a local database is
231             automatically provisioned.
233             To use this with a local database, set [](#opt-services.keycloak.database.createLocally) to
234             `false` and create the database and user
235             manually.
236           '';
237         };
239         passwordFile = mkOption {
240           type = path;
241           example = "/run/keys/db_password";
242           apply = assertStringPath "passwordFile";
243           description = ''
244             The path to a file containing the database password.
245           '';
246         };
247       };
249       package = mkPackageOption pkgs "keycloak" { };
251       initialAdminPassword = mkOption {
252         type = str;
253         default = "changeme";
254         description = ''
255           Initial password set for the `admin`
256           user. The password is not stored safely and should be changed
257           immediately in the admin panel.
258         '';
259       };
261       themes = mkOption {
262         type = attrsOf package;
263         default = { };
264         description = ''
265           Additional theme packages for Keycloak. Each theme is linked into
266           subdirectory with a corresponding attribute name.
268           Theme packages consist of several subdirectories which provide
269           different theme types: for example, `account`,
270           `login` etc. After adding a theme to this option you
271           can select it by its name in Keycloak administration console.
272         '';
273       };
275       settings = mkOption {
276         type = lib.types.submodule {
277           freeformType = attrsOf (nullOr (oneOf [ str int bool (attrsOf path) ]));
279           options = {
280             http-host = mkOption {
281               type = str;
282               default = "0.0.0.0";
283               example = "127.0.0.1";
284               description = ''
285                 On which address Keycloak should accept new connections.
286               '';
287             };
289             http-port = mkOption {
290               type = port;
291               default = 80;
292               example = 8080;
293               description = ''
294                 On which port Keycloak should listen for new HTTP connections.
295               '';
296             };
298             https-port = mkOption {
299               type = port;
300               default = 443;
301               example = 8443;
302               description = ''
303                 On which port Keycloak should listen for new HTTPS connections.
304               '';
305             };
307             http-relative-path = mkOption {
308               type = str;
309               default = "/";
310               example = "/auth";
311               apply = x: if !(hasPrefix "/") x then "/" + x else x;
312               description = ''
313                 The path relative to `/` for serving
314                 resources.
316                 ::: {.note}
317                 In versions of Keycloak using Wildfly (<17),
318                 this defaulted to `/auth`. If
319                 upgrading from the Wildfly version of Keycloak,
320                 i.e. a NixOS version before 22.05, you'll likely
321                 want to set this to `/auth` to
322                 keep compatibility with your clients.
324                 See <https://www.keycloak.org/migration/migrating-to-quarkus>
325                 for more information on migrating from Wildfly to Quarkus.
326                 :::
327               '';
328             };
330             hostname = mkOption {
331               type = nullOr str;
332               example = "keycloak.example.com";
333               description = ''
334                 The hostname part of the public URL used as base for
335                 all frontend requests.
337                 See <https://www.keycloak.org/server/hostname>
338                 for more information about hostname configuration.
339               '';
340             };
342             hostname-backchannel-dynamic = mkOption {
343               type = bool;
344               default = false;
345               example = true;
346               description = ''
347                 Enables dynamic resolving of backchannel URLs,
348                 including hostname, scheme, port and context path.
350                 See <https://www.keycloak.org/server/hostname>
351                 for more information about hostname configuration.
352               '';
353             };
355             proxy = mkOption {
356               type = enum [ "edge" "reencrypt" "passthrough" "none" ];
357               default = "none";
358               example = "edge";
359               description = ''
360                 The proxy address forwarding mode if the server is
361                 behind a reverse proxy.
363                 - `edge`:
364                   Enables communication through HTTP between the
365                   proxy and Keycloak.
366                 - `reencrypt`:
367                   Requires communication through HTTPS between the
368                   proxy and Keycloak.
369                 - `passthrough`:
370                   Enables communication through HTTP or HTTPS between
371                   the proxy and Keycloak.
373                 See <https://www.keycloak.org/server/reverseproxy> for more information.
374               '';
375             };
376           };
377         };
379         example = literalExpression ''
380           {
381             hostname = "keycloak.example.com";
382             proxy = "reencrypt";
383             https-key-store-file = "/path/to/file";
384             https-key-store-password = { _secret = "/run/keys/store_password"; };
385           }
386         '';
388         description = ''
389           Configuration options corresponding to parameters set in
390           {file}`conf/keycloak.conf`.
392           Most available options are documented at <https://www.keycloak.org/server/all-config>.
394           Options containing secret data should be set to an attribute
395           set containing the attribute `_secret` - a
396           string pointing to a file containing the value the option
397           should be set to. See the example to get a better picture of
398           this: in the resulting
399           {file}`conf/keycloak.conf` file, the
400           `https-key-store-password` key will be set
401           to the contents of the
402           {file}`/run/keys/store_password` file.
403         '';
404       };
405     };
407   config =
408     let
409       # We only want to create a database if we're actually going to
410       # connect to it.
411       databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "localhost";
412       createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.database.type == "postgresql";
413       createLocalMySQL = databaseActuallyCreateLocally && elem cfg.database.type [ "mysql" "mariadb" ];
415       mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" { } ''
416         ${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.database.caCert} -keystore $out -storepass notsosecretpassword -noprompt
417       '';
419       # Both theme and theme type directories need to be actual
420       # directories in one hierarchy to pass Keycloak checks.
421       themesBundle = pkgs.runCommand "keycloak-themes" { } ''
422         linkTheme() {
423           theme="$1"
424           name="$2"
426           mkdir "$out/$name"
427           for typeDir in "$theme"/*; do
428             if [ -d "$typeDir" ]; then
429               type="$(basename "$typeDir")"
430               mkdir "$out/$name/$type"
431               for file in "$typeDir"/*; do
432                 ln -sn "$file" "$out/$name/$type/$(basename "$file")"
433               done
434             fi
435           done
436         }
438         mkdir -p "$out"
439         for theme in ${keycloakBuild}/themes/*; do
440           if [ -d "$theme" ]; then
441             linkTheme "$theme" "$(basename "$theme")"
442           fi
443         done
445         ${concatStringsSep "\n" (mapAttrsToList (name: theme: "linkTheme ${theme} ${escapeShellArg name}") cfg.themes)}
446       '';
448       keycloakConfig = lib.generators.toKeyValue {
449         mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
450           mkValueString = v:
451             if isInt v then toString v
452             else if isString v then v
453             else if true == v then "true"
454             else if false == v then "false"
455             else if isSecret v then hashString "sha256" v._secret
456             else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
457         };
458       };
460       isSecret = v: isAttrs v && v ? _secret && isString v._secret;
461       filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [{ } null])) cfg.settings;
462       confFile = pkgs.writeText "keycloak.conf" (keycloakConfig filteredConfig);
463       keycloakBuild = cfg.package.override {
464         inherit confFile;
465         plugins = cfg.package.enabledPlugins ++ cfg.plugins ++
466                   (with cfg.package.plugins; [quarkus-systemd-notify quarkus-systemd-notify-deployment]);
467       };
468     in
469     mkIf cfg.enable
470       {
471         assertions = [
472           {
473             assertion = (cfg.database.useSSL && cfg.database.type == "postgresql") -> (cfg.database.caCert != null);
474             message = "A CA certificate must be specified (in 'services.keycloak.database.caCert') when PostgreSQL is used with SSL";
475           }
476           {
477             assertion = createLocalPostgreSQL -> config.services.postgresql.settings.standard_conforming_strings or true;
478             message = "Setting up a local PostgreSQL db for Keycloak requires `standard_conforming_strings` turned on to work reliably";
479           }
480           {
481             assertion = cfg.settings.hostname != null || ! cfg.settings.hostname-strict or true;
482             message = "Setting the Keycloak hostname is required, see `services.keycloak.settings.hostname`";
483           }
484           {
485             assertion = cfg.settings.hostname-url or null == null;
486             message = ''
487               The option `services.keycloak.settings.hostname-url' has been removed.
488               Set `services.keycloak.settings.hostname' instead.
489               See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
490             '';
491           }
492           {
493             assertion = cfg.settings.hostname-strict-backchannel or null == null;
494             message = ''
495               The option `services.keycloak.settings.hostname-strict-backchannel' has been removed.
496               Set `services.keycloak.settings.hostname-backchannel-dynamic' instead.
497               See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
498             '';
499           }
500         ];
502         environment.systemPackages = [ keycloakBuild ];
504         services.keycloak.settings =
505           let
506             postgresParams = concatStringsSep "&" (
507               optionals cfg.database.useSSL [
508                 "ssl=true"
509               ] ++ optionals (cfg.database.caCert != null) [
510                 "sslrootcert=${cfg.database.caCert}"
511                 "sslmode=verify-ca"
512               ]
513             );
514             mariadbParams = concatStringsSep "&" ([
515               "characterEncoding=UTF-8"
516             ] ++ optionals cfg.database.useSSL [
517               "useSSL=true"
518               "requireSSL=true"
519               "verifyServerCertificate=true"
520             ] ++ optionals (cfg.database.caCert != null) [
521               "trustCertificateKeyStoreUrl=file:${mySqlCaKeystore}"
522               "trustCertificateKeyStorePassword=notsosecretpassword"
523             ]);
524             dbProps = if cfg.database.type == "postgresql" then postgresParams else mariadbParams;
525           in
526           mkMerge [
527             {
528               db = if cfg.database.type == "postgresql" then "postgres" else cfg.database.type;
529               db-username = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username;
530               db-password._secret = cfg.database.passwordFile;
531               db-url-host = cfg.database.host;
532               db-url-port = toString cfg.database.port;
533               db-url-database = if databaseActuallyCreateLocally then "keycloak" else cfg.database.name;
534               db-url-properties = prefixUnlessEmpty "?" dbProps;
535               db-url = null;
536             }
537             (mkIf (cfg.sslCertificate != null && cfg.sslCertificateKey != null) {
538               https-certificate-file = "/run/keycloak/ssl/ssl_cert";
539               https-certificate-key-file = "/run/keycloak/ssl/ssl_key";
540             })
541           ];
543         systemd.services.keycloakPostgreSQLInit = mkIf createLocalPostgreSQL {
544           after = [ "postgresql.service" ];
545           before = [ "keycloak.service" ];
546           bindsTo = [ "postgresql.service" ];
547           path = [ config.services.postgresql.package ];
548           serviceConfig = {
549             Type = "oneshot";
550             RemainAfterExit = true;
551             User = "postgres";
552             Group = "postgres";
553             LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
554           };
555           script = ''
556             set -o errexit -o pipefail -o nounset -o errtrace
557             shopt -s inherit_errexit
559             create_role="$(mktemp)"
560             trap 'rm -f "$create_role"' EXIT
562             # Read the password from the credentials directory and
563             # escape any single quotes by adding additional single
564             # quotes after them, following the rules laid out here:
565             # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS
566             db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
567             db_password="''${db_password//\'/\'\'}"
569             echo "CREATE ROLE keycloak WITH LOGIN PASSWORD '$db_password' CREATEDB" > "$create_role"
570             psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='keycloak'" | grep -q 1 || psql -tA --file="$create_role"
571             psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'keycloak'" | grep -q 1 || psql -tAc 'CREATE DATABASE "keycloak" OWNER "keycloak"'
572           '';
573         };
575         systemd.services.keycloakMySQLInit = mkIf createLocalMySQL {
576           after = [ "mysql.service" ];
577           before = [ "keycloak.service" ];
578           bindsTo = [ "mysql.service" ];
579           path = [ config.services.mysql.package ];
580           serviceConfig = {
581             Type = "oneshot";
582             RemainAfterExit = true;
583             User = config.services.mysql.user;
584             Group = config.services.mysql.group;
585             LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
586           };
587           script = ''
588             set -o errexit -o pipefail -o nounset -o errtrace
589             shopt -s inherit_errexit
591             # Read the password from the credentials directory and
592             # escape any single quotes by adding additional single
593             # quotes after them, following the rules laid out here:
594             # https://dev.mysql.com/doc/refman/8.0/en/string-literals.html
595             db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
596             db_password="''${db_password//\'/\'\'}"
598             ( echo "SET sql_mode = 'NO_BACKSLASH_ESCAPES';"
599               echo "CREATE USER IF NOT EXISTS 'keycloak'@'localhost' IDENTIFIED BY '$db_password';"
600               echo "CREATE DATABASE IF NOT EXISTS keycloak CHARACTER SET utf8 COLLATE utf8_unicode_ci;"
601               echo "GRANT ALL PRIVILEGES ON keycloak.* TO 'keycloak'@'localhost';"
602             ) | mysql -N
603           '';
604         };
606         systemd.services.keycloak =
607           let
608             databaseServices =
609               if createLocalPostgreSQL then [
610                 "keycloakPostgreSQLInit.service"
611                 "postgresql.service"
612               ]
613               else if createLocalMySQL then [
614                 "keycloakMySQLInit.service"
615                 "mysql.service"
616               ]
617               else [ ];
618             secretPaths = catAttrs "_secret" (collect isSecret cfg.settings);
619             mkSecretReplacement = file: ''
620               replace-secret ${hashString "sha256" file} $CREDENTIALS_DIRECTORY/${baseNameOf file} /run/keycloak/conf/keycloak.conf
621             '';
622             secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
623           in
624           {
625             after = databaseServices;
626             bindsTo = databaseServices;
627             wantedBy = [ "multi-user.target" ];
628             path = with pkgs; [
629               keycloakBuild
630               openssl
631               replace-secret
632             ];
633             environment = {
634               KC_HOME_DIR = "/run/keycloak";
635               KC_CONF_DIR = "/run/keycloak/conf";
636             };
637             serviceConfig = {
638               LoadCredential =
639                 map (p: "${baseNameOf p}:${p}") secretPaths
640                 ++ optionals (cfg.sslCertificate != null && cfg.sslCertificateKey != null) [
641                   "ssl_cert:${cfg.sslCertificate}"
642                   "ssl_key:${cfg.sslCertificateKey}"
643                 ];
644               User = "keycloak";
645               Group = "keycloak";
646               DynamicUser = true;
647               RuntimeDirectory = "keycloak";
648               RuntimeDirectoryMode = "0700";
649               AmbientCapabilities = "CAP_NET_BIND_SERVICE";
650               Type = "notify";  # Requires quarkus-systemd-notify plugin
651               NotifyAccess = "all";
652             };
653             script = ''
654               set -o errexit -o pipefail -o nounset -o errtrace
655               shopt -s inherit_errexit
657               umask u=rwx,g=,o=
659               ln -s ${themesBundle} /run/keycloak/themes
660               ln -s ${keycloakBuild}/providers /run/keycloak/
662               install -D -m 0600 ${confFile} /run/keycloak/conf/keycloak.conf
664               ${secretReplacements}
666               # Escape any backslashes in the db parameters, since
667               # they're otherwise unexpectedly read as escape
668               # sequences.
669               sed -i '/db-/ s|\\|\\\\|g' /run/keycloak/conf/keycloak.conf
671             '' + optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
672               mkdir -p /run/keycloak/ssl
673               cp $CREDENTIALS_DIRECTORY/ssl_{cert,key} /run/keycloak/ssl/
674             '' + ''
675               export KEYCLOAK_ADMIN=admin
676               export KEYCLOAK_ADMIN_PASSWORD=${escapeShellArg cfg.initialAdminPassword}
677               kc.sh --verbose start --optimized
678             '';
679           };
681         services.postgresql.enable = mkDefault createLocalPostgreSQL;
682         services.mysql.enable = mkDefault createLocalMySQL;
683         services.mysql.package =
684           let
685             dbPkg = if cfg.database.type == "mariadb" then pkgs.mariadb else pkgs.mysql80;
686           in
687           mkIf createLocalMySQL (mkDefault dbPkg);
688       };
690   meta.doc = ./keycloak.md;
691   meta.maintainers = [ maintainers.talyz ];