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