at 23.11-pre 24 kB view raw
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 (&lt;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}