1{ config, options, pkgs, lib, ... }:
2
3let
4 cfg = config.services.keycloak;
5 opt = options.services.keycloak;
6
7 inherit (lib)
8 types
9 mkMerge
10 mkOption
11 mkChangedOptionModule
12 mkRenamedOptionModule
13 mkRemovedOptionModule
14 concatStringsSep
15 mapAttrsToList
16 escapeShellArg
17 mkIf
18 optionalString
19 optionals
20 mkDefault
21 literalExpression
22 isAttrs
23 literalMD
24 maintainers
25 catAttrs
26 collect
27 splitString
28 hasPrefix
29 ;
30
31 inherit (builtins)
32 elem
33 typeOf
34 isInt
35 isString
36 hashString
37 isPath
38 ;
39
40 prefixUnlessEmpty = prefix: string: optionalString (string != "") "${prefix}${string}";
41in
42{
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 ];
72
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;
86
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 = lib.mdDoc ''
103 Whether to enable the Keycloak identity and access management
104 server.
105 '';
106 };
107
108 sslCertificate = mkOption {
109 type = nullOr path;
110 default = null;
111 example = "/run/keys/ssl_cert";
112 apply = assertStringPath "sslCertificate";
113 description = lib.mdDoc ''
114 The path to a PEM formatted certificate to use for TLS/SSL
115 connections.
116 '';
117 };
118
119 sslCertificateKey = mkOption {
120 type = nullOr path;
121 default = null;
122 example = "/run/keys/ssl_key";
123 apply = assertStringPath "sslCertificateKey";
124 description = lib.mdDoc ''
125 The path to a PEM formatted private key to use for TLS/SSL
126 connections.
127 '';
128 };
129
130 plugins = lib.mkOption {
131 type = lib.types.listOf lib.types.path;
132 default = [ ];
133 description = lib.mdDoc ''
134 Keycloak plugin jar, ear files or derivations containing
135 them. Packaged plugins are available through
136 `pkgs.keycloak.plugins`.
137 '';
138 };
139
140 database = {
141 type = mkOption {
142 type = enum [ "mysql" "mariadb" "postgresql" ];
143 default = "postgresql";
144 example = "mariadb";
145 description = lib.mdDoc ''
146 The type of database Keycloak should connect to.
147 '';
148 };
149
150 host = mkOption {
151 type = str;
152 default = "localhost";
153 description = lib.mdDoc ''
154 Hostname of the database to connect to.
155 '';
156 };
157
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 = lib.mdDoc ''
171 Port of the database to connect to.
172 '';
173 };
174
175 useSSL = mkOption {
176 type = bool;
177 default = cfg.database.host != "localhost";
178 defaultText = literalExpression ''config.${opt.database.host} != "localhost"'';
179 description = lib.mdDoc ''
180 Whether the database connection should be secured by SSL /
181 TLS.
182 '';
183 };
184
185 caCert = mkOption {
186 type = nullOr path;
187 default = null;
188 description = lib.mdDoc ''
189 The SSL / TLS CA certificate that verifies the identity of the
190 database server.
191
192 Required when PostgreSQL is used and SSL is turned on.
193
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 };
199
200 createLocally = mkOption {
201 type = bool;
202 default = true;
203 description = lib.mdDoc ''
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 };
210
211 name = mkOption {
212 type = str;
213 default = "keycloak";
214 description = lib.mdDoc ''
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.
218
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 };
224
225 username = mkOption {
226 type = str;
227 default = "keycloak";
228 description = lib.mdDoc ''
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.
232
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 };
238
239 passwordFile = mkOption {
240 type = path;
241 example = "/run/keys/db_password";
242 apply = assertStringPath "passwordFile";
243 description = lib.mdDoc ''
244 The path to a file containing the database password.
245 '';
246 };
247 };
248
249 package = mkOption {
250 type = package;
251 default = pkgs.keycloak;
252 defaultText = literalExpression "pkgs.keycloak";
253 description = lib.mdDoc ''
254 Keycloak package to use.
255 '';
256 };
257
258 initialAdminPassword = mkOption {
259 type = str;
260 default = "changeme";
261 description = lib.mdDoc ''
262 Initial password set for the `admin`
263 user. The password is not stored safely and should be changed
264 immediately in the admin panel.
265 '';
266 };
267
268 themes = mkOption {
269 type = attrsOf package;
270 default = { };
271 description = lib.mdDoc ''
272 Additional theme packages for Keycloak. Each theme is linked into
273 subdirectory with a corresponding attribute name.
274
275 Theme packages consist of several subdirectories which provide
276 different theme types: for example, `account`,
277 `login` etc. After adding a theme to this option you
278 can select it by its name in Keycloak administration console.
279 '';
280 };
281
282 settings = mkOption {
283 type = lib.types.submodule {
284 freeformType = attrsOf (nullOr (oneOf [ str int bool (attrsOf path) ]));
285
286 options = {
287 http-host = mkOption {
288 type = str;
289 default = "0.0.0.0";
290 example = "127.0.0.1";
291 description = lib.mdDoc ''
292 On which address Keycloak should accept new connections.
293 '';
294 };
295
296 http-port = mkOption {
297 type = port;
298 default = 80;
299 example = 8080;
300 description = lib.mdDoc ''
301 On which port Keycloak should listen for new HTTP connections.
302 '';
303 };
304
305 https-port = mkOption {
306 type = port;
307 default = 443;
308 example = 8443;
309 description = lib.mdDoc ''
310 On which port Keycloak should listen for new HTTPS connections.
311 '';
312 };
313
314 http-relative-path = mkOption {
315 type = str;
316 default = "/";
317 example = "/auth";
318 apply = x: if !(hasPrefix "/") x then "/" + x else x;
319 description = lib.mdDoc ''
320 The path relative to `/` for serving
321 resources.
322
323 ::: {.note}
324 In versions of Keycloak using Wildfly (<17),
325 this defaulted to `/auth`. If
326 upgrading from the Wildfly version of Keycloak,
327 i.e. a NixOS version before 22.05, you'll likely
328 want to set this to `/auth` to
329 keep compatibility with your clients.
330
331 See <https://www.keycloak.org/migration/migrating-to-quarkus>
332 for more information on migrating from Wildfly to Quarkus.
333 :::
334 '';
335 };
336
337 hostname = mkOption {
338 type = str;
339 example = "keycloak.example.com";
340 description = lib.mdDoc ''
341 The hostname part of the public URL used as base for
342 all frontend requests.
343
344 See <https://www.keycloak.org/server/hostname>
345 for more information about hostname configuration.
346 '';
347 };
348
349 hostname-strict-backchannel = mkOption {
350 type = bool;
351 default = false;
352 example = true;
353 description = lib.mdDoc ''
354 Whether Keycloak should force all requests to go
355 through the frontend URL. By default, Keycloak allows
356 backend requests to instead use its local hostname or
357 IP address and may also advertise it to clients
358 through its OpenID Connect Discovery endpoint.
359
360 See <https://www.keycloak.org/server/hostname>
361 for more information about hostname configuration.
362 '';
363 };
364
365 proxy = mkOption {
366 type = enum [ "edge" "reencrypt" "passthrough" "none" ];
367 default = "none";
368 example = "edge";
369 description = lib.mdDoc ''
370 The proxy address forwarding mode if the server is
371 behind a reverse proxy.
372
373 - `edge`:
374 Enables communication through HTTP between the
375 proxy and Keycloak.
376 - `reencrypt`:
377 Requires communication through HTTPS between the
378 proxy and Keycloak.
379 - `passthrough`:
380 Enables communication through HTTP or HTTPS between
381 the proxy and Keycloak.
382
383 See <https://www.keycloak.org/server/reverseproxy> for more information.
384 '';
385 };
386 };
387 };
388
389 example = literalExpression ''
390 {
391 hostname = "keycloak.example.com";
392 proxy = "reencrypt";
393 https-key-store-file = "/path/to/file";
394 https-key-store-password = { _secret = "/run/keys/store_password"; };
395 }
396 '';
397
398 description = lib.mdDoc ''
399 Configuration options corresponding to parameters set in
400 {file}`conf/keycloak.conf`.
401
402 Most available options are documented at <https://www.keycloak.org/server/all-config>.
403
404 Options containing secret data should be set to an attribute
405 set containing the attribute `_secret` - a
406 string pointing to a file containing the value the option
407 should be set to. See the example to get a better picture of
408 this: in the resulting
409 {file}`conf/keycloak.conf` file, the
410 `https-key-store-password` key will be set
411 to the contents of the
412 {file}`/run/keys/store_password` file.
413 '';
414 };
415 };
416
417 config =
418 let
419 # We only want to create a database if we're actually going to
420 # connect to it.
421 databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "localhost";
422 createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.database.type == "postgresql";
423 createLocalMySQL = databaseActuallyCreateLocally && elem cfg.database.type [ "mysql" "mariadb" ];
424
425 mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" { } ''
426 ${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.database.caCert} -keystore $out -storepass notsosecretpassword -noprompt
427 '';
428
429 # Both theme and theme type directories need to be actual
430 # directories in one hierarchy to pass Keycloak checks.
431 themesBundle = pkgs.runCommand "keycloak-themes" { } ''
432 linkTheme() {
433 theme="$1"
434 name="$2"
435
436 mkdir "$out/$name"
437 for typeDir in "$theme"/*; do
438 if [ -d "$typeDir" ]; then
439 type="$(basename "$typeDir")"
440 mkdir "$out/$name/$type"
441 for file in "$typeDir"/*; do
442 ln -sn "$file" "$out/$name/$type/$(basename "$file")"
443 done
444 fi
445 done
446 }
447
448 mkdir -p "$out"
449 for theme in ${keycloakBuild}/themes/*; do
450 if [ -d "$theme" ]; then
451 linkTheme "$theme" "$(basename "$theme")"
452 fi
453 done
454
455 ${concatStringsSep "\n" (mapAttrsToList (name: theme: "linkTheme ${theme} ${escapeShellArg name}") cfg.themes)}
456 '';
457
458 keycloakConfig = lib.generators.toKeyValue {
459 mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
460 mkValueString = v: with builtins;
461 if isInt v then toString v
462 else if isString v then v
463 else if true == v then "true"
464 else if false == v then "false"
465 else if isSecret v then hashString "sha256" v._secret
466 else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
467 };
468 };
469
470 isSecret = v: isAttrs v && v ? _secret && isString v._secret;
471 filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [{ } null])) cfg.settings;
472 confFile = pkgs.writeText "keycloak.conf" (keycloakConfig filteredConfig);
473 keycloakBuild = cfg.package.override {
474 inherit confFile;
475 plugins = cfg.package.enabledPlugins ++ cfg.plugins;
476 };
477 in
478 mkIf cfg.enable
479 {
480 assertions = [
481 {
482 assertion = (cfg.database.useSSL && cfg.database.type == "postgresql") -> (cfg.database.caCert != null);
483 message = "A CA certificate must be specified (in 'services.keycloak.database.caCert') when PostgreSQL is used with SSL";
484 }
485 {
486 assertion = createLocalPostgreSQL -> config.services.postgresql.settings.standard_conforming_strings or true;
487 message = "Setting up a local PostgreSQL db for Keycloak requires `standard_conforming_strings` turned on to work reliably";
488 }
489 ];
490
491 environment.systemPackages = [ keycloakBuild ];
492
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 ];
531
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
547
548 create_role="$(mktemp)"
549 trap 'rm -f "$create_role"' EXIT
550
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//\'/\'\'}"
557
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 };
563
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
579
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//\'/\'\'}"
586
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 };
594
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 };
626 serviceConfig = {
627 LoadCredential =
628 map (p: "${baseNameOf p}:${p}") secretPaths
629 ++ optionals (cfg.sslCertificate != null && cfg.sslCertificateKey != null) [
630 "ssl_cert:${cfg.sslCertificate}"
631 "ssl_key:${cfg.sslCertificateKey}"
632 ];
633 User = "keycloak";
634 Group = "keycloak";
635 DynamicUser = true;
636 RuntimeDirectory = "keycloak";
637 RuntimeDirectoryMode = "0700";
638 AmbientCapabilities = "CAP_NET_BIND_SERVICE";
639 };
640 script = ''
641 set -o errexit -o pipefail -o nounset -o errtrace
642 shopt -s inherit_errexit
643
644 umask u=rwx,g=,o=
645
646 ln -s ${themesBundle} /run/keycloak/themes
647 ln -s ${keycloakBuild}/providers /run/keycloak/
648
649 install -D -m 0600 ${confFile} /run/keycloak/conf/keycloak.conf
650
651 ${secretReplacements}
652
653 # Escape any backslashes in the db parameters, since
654 # they're otherwise unexpectedly read as escape
655 # sequences.
656 sed -i '/db-/ s|\\|\\\\|g' /run/keycloak/conf/keycloak.conf
657
658 '' + optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
659 mkdir -p /run/keycloak/ssl
660 cp $CREDENTIALS_DIRECTORY/ssl_{cert,key} /run/keycloak/ssl/
661 '' + ''
662 export KEYCLOAK_ADMIN=admin
663 export KEYCLOAK_ADMIN_PASSWORD=${escapeShellArg cfg.initialAdminPassword}
664 kc.sh start --optimized
665 '';
666 };
667
668 services.postgresql.enable = mkDefault createLocalPostgreSQL;
669 services.mysql.enable = mkDefault createLocalMySQL;
670 services.mysql.package =
671 let
672 dbPkg = if cfg.database.type == "mariadb" then pkgs.mariadb else pkgs.mysql80;
673 in
674 mkIf createLocalMySQL (mkDefault dbPkg);
675 };
676
677 meta.doc = ./keycloak.md;
678 meta.maintainers = [ maintainers.talyz ];
679}