python312Packages.yoda: 2.0.1 -> 2.0.2
[NixPkgs.git] / nixos / modules / services / web-apps / keycloak.nix
blobb8dc9e2ef0dd316a977a26fbffcdfcbf95c82b0c
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 = nullOr str;
253         default = null;
254         description = ''
255           Initial password set for the temporary `admin` user.
256           The password is not stored safely and should be changed
257           immediately in the admin panel.
259           See [Admin bootstrap and recovery](https://www.keycloak.org/server/bootstrap-admin-recovery) for details.
260         '';
261       };
263       themes = mkOption {
264         type = attrsOf package;
265         default = { };
266         description = ''
267           Additional theme packages for Keycloak. Each theme is linked into
268           subdirectory with a corresponding attribute name.
270           Theme packages consist of several subdirectories which provide
271           different theme types: for example, `account`,
272           `login` etc. After adding a theme to this option you
273           can select it by its name in Keycloak administration console.
274         '';
275       };
277       settings = mkOption {
278         type = lib.types.submodule {
279           freeformType = attrsOf (nullOr (oneOf [ str int bool (attrsOf path) ]));
281           options = {
282             http-host = mkOption {
283               type = str;
284               default = "::";
285               example = "::1";
286               description = ''
287                 On which address Keycloak should accept new connections.
288               '';
289             };
291             http-port = mkOption {
292               type = port;
293               default = 80;
294               example = 8080;
295               description = ''
296                 On which port Keycloak should listen for new HTTP connections.
297               '';
298             };
300             https-port = mkOption {
301               type = port;
302               default = 443;
303               example = 8443;
304               description = ''
305                 On which port Keycloak should listen for new HTTPS connections.
306               '';
307             };
309             http-relative-path = mkOption {
310               type = str;
311               default = "/";
312               example = "/auth";
313               apply = x: if !(hasPrefix "/") x then "/" + x else x;
314               description = ''
315                 The path relative to `/` for serving
316                 resources.
318                 ::: {.note}
319                 In versions of Keycloak using Wildfly (<17),
320                 this defaulted to `/auth`. If
321                 upgrading from the Wildfly version of Keycloak,
322                 i.e. a NixOS version before 22.05, you'll likely
323                 want to set this to `/auth` to
324                 keep compatibility with your clients.
326                 See <https://www.keycloak.org/migration/migrating-to-quarkus>
327                 for more information on migrating from Wildfly to Quarkus.
328                 :::
329               '';
330             };
332             hostname = mkOption {
333               type = nullOr str;
334               example = "keycloak.example.com";
335               description = ''
336                 The hostname part of the public URL used as base for
337                 all frontend requests.
339                 See <https://www.keycloak.org/server/hostname>
340                 for more information about hostname configuration.
341               '';
342             };
344             hostname-backchannel-dynamic = mkOption {
345               type = bool;
346               default = false;
347               example = true;
348               description = ''
349                 Enables dynamic resolving of backchannel URLs,
350                 including hostname, scheme, port and context path.
352                 See <https://www.keycloak.org/server/hostname>
353                 for more information about hostname configuration.
354               '';
355             };
356           };
357         };
359         example = literalExpression ''
360           {
361             hostname = "keycloak.example.com";
362             https-key-store-file = "/path/to/file";
363             https-key-store-password = { _secret = "/run/keys/store_password"; };
364           }
365         '';
367         description = ''
368           Configuration options corresponding to parameters set in
369           {file}`conf/keycloak.conf`.
371           Most available options are documented at <https://www.keycloak.org/server/all-config>.
373           Options containing secret data should be set to an attribute
374           set containing the attribute `_secret` - a
375           string pointing to a file containing the value the option
376           should be set to. See the example to get a better picture of
377           this: in the resulting
378           {file}`conf/keycloak.conf` file, the
379           `https-key-store-password` key will be set
380           to the contents of the
381           {file}`/run/keys/store_password` file.
382         '';
383       };
384     };
386   config =
387     let
388       # We only want to create a database if we're actually going to
389       # connect to it.
390       databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "localhost";
391       createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.database.type == "postgresql";
392       createLocalMySQL = databaseActuallyCreateLocally && elem cfg.database.type [ "mysql" "mariadb" ];
394       mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" { } ''
395         ${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.database.caCert} -keystore $out -storepass notsosecretpassword -noprompt
396       '';
398       # Both theme and theme type directories need to be actual
399       # directories in one hierarchy to pass Keycloak checks.
400       themesBundle = pkgs.runCommand "keycloak-themes" { } ''
401         linkTheme() {
402           theme="$1"
403           name="$2"
405           mkdir "$out/$name"
406           for typeDir in "$theme"/*; do
407             if [ -d "$typeDir" ]; then
408               type="$(basename "$typeDir")"
409               mkdir "$out/$name/$type"
410               for file in "$typeDir"/*; do
411                 ln -sn "$file" "$out/$name/$type/$(basename "$file")"
412               done
413             fi
414           done
415         }
417         mkdir -p "$out"
418         for theme in ${keycloakBuild}/themes/*; do
419           if [ -d "$theme" ]; then
420             linkTheme "$theme" "$(basename "$theme")"
421           fi
422         done
424         ${concatStringsSep "\n" (mapAttrsToList (name: theme: "linkTheme ${theme} ${escapeShellArg name}") cfg.themes)}
425       '';
427       keycloakConfig = lib.generators.toKeyValue {
428         mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
429           mkValueString = v:
430             if isInt v then toString v
431             else if isString v then v
432             else if true == v then "true"
433             else if false == v then "false"
434             else if isSecret v then hashString "sha256" v._secret
435             else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
436         };
437       };
439       isSecret = v: isAttrs v && v ? _secret && isString v._secret;
440       filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [{ } null])) cfg.settings;
441       confFile = pkgs.writeText "keycloak.conf" (keycloakConfig filteredConfig);
442       keycloakBuild = cfg.package.override {
443         inherit confFile;
444         plugins = cfg.package.enabledPlugins ++ cfg.plugins ++
445                   (with cfg.package.plugins; [quarkus-systemd-notify quarkus-systemd-notify-deployment]);
446       };
447     in
448     mkIf cfg.enable
449       {
450         assertions = [
451           {
452             assertion = (cfg.database.useSSL && cfg.database.type == "postgresql") -> (cfg.database.caCert != null);
453             message = "A CA certificate must be specified (in 'services.keycloak.database.caCert') when PostgreSQL is used with SSL";
454           }
455           {
456             assertion = createLocalPostgreSQL -> config.services.postgresql.settings.standard_conforming_strings or true;
457             message = "Setting up a local PostgreSQL db for Keycloak requires `standard_conforming_strings` turned on to work reliably";
458           }
459           {
460             assertion = cfg.settings.hostname != null || ! cfg.settings.hostname-strict or true;
461             message = "Setting the Keycloak hostname is required, see `services.keycloak.settings.hostname`";
462           }
463           {
464             assertion = cfg.settings.hostname-url or null == null;
465             message = ''
466               The option `services.keycloak.settings.hostname-url' has been removed.
467               Set `services.keycloak.settings.hostname' instead.
468               See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
469             '';
470           }
471           {
472             assertion = cfg.settings.hostname-strict-backchannel or null == null;
473             message = ''
474               The option `services.keycloak.settings.hostname-strict-backchannel' has been removed.
475               Set `services.keycloak.settings.hostname-backchannel-dynamic' instead.
476               See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
477             '';
478           }
479           {
480             assertion = cfg.settings.proxy or null == null;
481             message = ''
482               The option `services.keycloak.settings.proxy' has been removed.
483               Set `services.keycloak.settings.proxy-headers` in combination
484               with other hostname options as needed instead.
485               See [Proxy option removed](https://www.keycloak.org/docs/latest/upgrading/index.html#proxy-option-removed)
486               for more information.
487             '';
488           }
489         ];
491         environment.systemPackages = [ keycloakBuild ];
493         services.keycloak.settings =
494           let
495             postgresParams = concatStringsSep "&" (
496               optionals cfg.database.useSSL [
497                 "ssl=true"
498               ] ++ optionals (cfg.database.caCert != null) [
499                 "sslrootcert=${cfg.database.caCert}"
500                 "sslmode=verify-ca"
501               ]
502             );
503             mariadbParams = concatStringsSep "&" ([
504               "characterEncoding=UTF-8"
505             ] ++ optionals cfg.database.useSSL [
506               "useSSL=true"
507               "requireSSL=true"
508               "verifyServerCertificate=true"
509             ] ++ optionals (cfg.database.caCert != null) [
510               "trustCertificateKeyStoreUrl=file:${mySqlCaKeystore}"
511               "trustCertificateKeyStorePassword=notsosecretpassword"
512             ]);
513             dbProps = if cfg.database.type == "postgresql" then postgresParams else mariadbParams;
514           in
515           mkMerge [
516             {
517               db = if cfg.database.type == "postgresql" then "postgres" else cfg.database.type;
518               db-username = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username;
519               db-password._secret = cfg.database.passwordFile;
520               db-url-host = cfg.database.host;
521               db-url-port = toString cfg.database.port;
522               db-url-database = if databaseActuallyCreateLocally then "keycloak" else cfg.database.name;
523               db-url-properties = prefixUnlessEmpty "?" dbProps;
524               db-url = null;
525             }
526             (mkIf (cfg.sslCertificate != null && cfg.sslCertificateKey != null) {
527               https-certificate-file = "/run/keycloak/ssl/ssl_cert";
528               https-certificate-key-file = "/run/keycloak/ssl/ssl_key";
529             })
530           ];
532         systemd.services.keycloakPostgreSQLInit = mkIf createLocalPostgreSQL {
533           after = [ "postgresql.service" ];
534           before = [ "keycloak.service" ];
535           bindsTo = [ "postgresql.service" ];
536           path = [ config.services.postgresql.package ];
537           serviceConfig = {
538             Type = "oneshot";
539             RemainAfterExit = true;
540             User = "postgres";
541             Group = "postgres";
542             LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
543           };
544           script = ''
545             set -o errexit -o pipefail -o nounset -o errtrace
546             shopt -s inherit_errexit
548             create_role="$(mktemp)"
549             trap 'rm -f "$create_role"' EXIT
551             # Read the password from the credentials directory and
552             # escape any single quotes by adding additional single
553             # quotes after them, following the rules laid out here:
554             # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS
555             db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
556             db_password="''${db_password//\'/\'\'}"
558             echo "CREATE ROLE keycloak WITH LOGIN PASSWORD '$db_password' CREATEDB" > "$create_role"
559             psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='keycloak'" | grep -q 1 || psql -tA --file="$create_role"
560             psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'keycloak'" | grep -q 1 || psql -tAc 'CREATE DATABASE "keycloak" OWNER "keycloak"'
561           '';
562         };
564         systemd.services.keycloakMySQLInit = mkIf createLocalMySQL {
565           after = [ "mysql.service" ];
566           before = [ "keycloak.service" ];
567           bindsTo = [ "mysql.service" ];
568           path = [ config.services.mysql.package ];
569           serviceConfig = {
570             Type = "oneshot";
571             RemainAfterExit = true;
572             User = config.services.mysql.user;
573             Group = config.services.mysql.group;
574             LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
575           };
576           script = ''
577             set -o errexit -o pipefail -o nounset -o errtrace
578             shopt -s inherit_errexit
580             # Read the password from the credentials directory and
581             # escape any single quotes by adding additional single
582             # quotes after them, following the rules laid out here:
583             # https://dev.mysql.com/doc/refman/8.0/en/string-literals.html
584             db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
585             db_password="''${db_password//\'/\'\'}"
587             ( echo "SET sql_mode = 'NO_BACKSLASH_ESCAPES';"
588               echo "CREATE USER IF NOT EXISTS 'keycloak'@'localhost' IDENTIFIED BY '$db_password';"
589               echo "CREATE DATABASE IF NOT EXISTS keycloak CHARACTER SET utf8 COLLATE utf8_unicode_ci;"
590               echo "GRANT ALL PRIVILEGES ON keycloak.* TO 'keycloak'@'localhost';"
591             ) | mysql -N
592           '';
593         };
595         systemd.services.keycloak =
596           let
597             databaseServices =
598               if createLocalPostgreSQL then [
599                 "keycloakPostgreSQLInit.service"
600                 "postgresql.service"
601               ]
602               else if createLocalMySQL then [
603                 "keycloakMySQLInit.service"
604                 "mysql.service"
605               ]
606               else [ ];
607             secretPaths = catAttrs "_secret" (collect isSecret cfg.settings);
608             mkSecretReplacement = file: ''
609               replace-secret ${hashString "sha256" file} $CREDENTIALS_DIRECTORY/${baseNameOf file} /run/keycloak/conf/keycloak.conf
610             '';
611             secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
612           in
613           {
614             after = databaseServices;
615             bindsTo = databaseServices;
616             wantedBy = [ "multi-user.target" ];
617             path = with pkgs; [
618               keycloakBuild
619               openssl
620               replace-secret
621             ];
622             environment = {
623               KC_HOME_DIR = "/run/keycloak";
624               KC_CONF_DIR = "/run/keycloak/conf";
625             } // lib.optionalAttrs (cfg.initialAdminPassword != null) {
626               KC_BOOTSTRAP_ADMIN_USERNAME = "admin";
627               KC_BOOTSTRAP_ADMIN_PASSWORD = cfg.initialAdminPassword;
628             };
629             serviceConfig = {
630               LoadCredential =
631                 map (p: "${baseNameOf p}:${p}") secretPaths
632                 ++ optionals (cfg.sslCertificate != null && cfg.sslCertificateKey != null) [
633                   "ssl_cert:${cfg.sslCertificate}"
634                   "ssl_key:${cfg.sslCertificateKey}"
635                 ];
636               User = "keycloak";
637               Group = "keycloak";
638               DynamicUser = true;
639               RuntimeDirectory = "keycloak";
640               RuntimeDirectoryMode = "0700";
641               AmbientCapabilities = "CAP_NET_BIND_SERVICE";
642               Type = "notify";  # Requires quarkus-systemd-notify plugin
643               NotifyAccess = "all";
644             };
645             script = ''
646               set -o errexit -o pipefail -o nounset -o errtrace
647               shopt -s inherit_errexit
649               umask u=rwx,g=,o=
651               ln -s ${themesBundle} /run/keycloak/themes
652               ln -s ${keycloakBuild}/providers /run/keycloak/
653               ln -s ${keycloakBuild}/lib /run/keycloak/
655               install -D -m 0600 ${confFile} /run/keycloak/conf/keycloak.conf
657               ${secretReplacements}
659               # Escape any backslashes in the db parameters, since
660               # they're otherwise unexpectedly read as escape
661               # sequences.
662               sed -i '/db-/ s|\\|\\\\|g' /run/keycloak/conf/keycloak.conf
664             '' + optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
665               mkdir -p /run/keycloak/ssl
666               cp $CREDENTIALS_DIRECTORY/ssl_{cert,key} /run/keycloak/ssl/
667             '' + ''
668               kc.sh --verbose start --optimized
669             '';
670           };
672         services.postgresql.enable = mkDefault createLocalPostgreSQL;
673         services.mysql.enable = mkDefault createLocalMySQL;
674         services.mysql.package =
675           let
676             dbPkg = if cfg.database.type == "mariadb" then pkgs.mariadb else pkgs.mysql80;
677           in
678           mkIf createLocalMySQL (mkDefault dbPkg);
679       };
681   meta.doc = ./keycloak.md;
682   meta.maintainers = [ maintainers.talyz ];