1# This tests Keycloak: it starts the service, creates a realm with an
2# OIDC client and a user, and simulates the user logging in to the
3# client using their Keycloak login.
4
5let
6 certs = import ./common/acme/server/snakeoil-certs.nix;
7 frontendUrl = "https://${certs.domain}";
8
9 keycloakTest = databaseType: import ./make-test-python.nix (
10 { pkgs, ... }:
11 let
12 initialAdminPassword = "h4Iho\"JFn't2>iQIR9";
13 adminPasswordFile = pkgs.writeText "admin-password" "${initialAdminPassword}";
14 in
15 {
16 name = "keycloak";
17 meta = with pkgs.lib.maintainers; {
18 maintainers = [ talyz ];
19 };
20
21 nodes = {
22 keycloak = { config, ... }: {
23 security.pki.certificateFiles = [
24 certs.ca.cert
25 ];
26
27 networking.extraHosts = ''
28 127.0.0.1 ${certs.domain}
29 '';
30
31 services.keycloak = {
32 enable = true;
33 settings = {
34 hostname = certs.domain;
35 };
36 inherit initialAdminPassword;
37 sslCertificate = "${certs.${certs.domain}.cert}";
38 sslCertificateKey = "${certs.${certs.domain}.key}";
39 database = {
40 type = databaseType;
41 username = "bogus";
42 name = "also bogus";
43 passwordFile = "${pkgs.writeText "dbPassword" ''wzf6\"vO"Cb\nP>p#6;c&o?eu=q'THE'''H''''E''}";
44 };
45 plugins = with config.services.keycloak.package.plugins; [
46 keycloak-discord
47 keycloak-metrics-spi
48 ];
49 };
50 environment.systemPackages = with pkgs; [
51 xmlstarlet
52 html-tidy
53 jq
54 ];
55 };
56 };
57
58 testScript =
59 let
60 client = {
61 clientId = "test-client";
62 name = "test-client";
63 redirectUris = [ "urn:ietf:wg:oauth:2.0:oob" ];
64 };
65
66 user = {
67 firstName = "Chuck";
68 lastName = "Testa";
69 username = "chuck.testa";
70 email = "chuck.testa@example.com";
71 };
72
73 password = "password1234";
74
75 realm = {
76 enabled = true;
77 realm = "test-realm";
78 clients = [ client ];
79 users = [
80 (
81 user // {
82 enabled = true;
83 credentials = [{
84 type = "password";
85 temporary = false;
86 value = password;
87 }];
88 }
89 )
90 ];
91 };
92
93 realmDataJson = pkgs.writeText "realm-data.json" (builtins.toJSON realm);
94
95 jqCheckUserinfo = pkgs.writeText "check-userinfo.jq" ''
96 if {
97 "firstName": .given_name,
98 "lastName": .family_name,
99 "username": .preferred_username,
100 "email": .email
101 } != ${builtins.toJSON user} then
102 error("Wrong user info!")
103 else
104 empty
105 end
106 '';
107 in ''
108 keycloak.start()
109 keycloak.wait_for_unit("keycloak.service")
110 keycloak.wait_for_open_port(443)
111 keycloak.wait_until_succeeds("curl -sSf ${frontendUrl}")
112
113 ### Realm Setup ###
114
115 # Get an admin interface access token
116 keycloak.succeed("""
117 curl -sSf -d 'client_id=admin-cli' \
118 -d 'username=admin' \
119 -d "password=$(<${adminPasswordFile})" \
120 -d 'grant_type=password' \
121 '${frontendUrl}/realms/master/protocol/openid-connect/token' \
122 | jq -r '"Authorization: bearer " + .access_token' >admin_auth_header
123 """)
124
125 # Register the metrics SPI
126 keycloak.succeed(
127 """${pkgs.jre}/bin/keytool -import -alias snakeoil -file ${certs.ca.cert} -storepass aaaaaa -keystore cacert.jks -noprompt""",
128 """KC_OPTS='-Djavax.net.ssl.trustStore=cacert.jks -Djavax.net.ssl.trustStorePassword=aaaaaa' kcadm.sh config credentials --server '${frontendUrl}' --realm master --user admin --password "$(<${adminPasswordFile})" """,
129 """KC_OPTS='-Djavax.net.ssl.trustStore=cacert.jks -Djavax.net.ssl.trustStorePassword=aaaaaa' kcadm.sh update events/config -s 'eventsEnabled=true' -s 'adminEventsEnabled=true' -s 'eventsListeners+=metrics-listener'""",
130 """curl -sSf '${frontendUrl}/realms/master/metrics' | grep '^keycloak_admin_event_UPDATE'"""
131 )
132
133 # Publish the realm, including a test OIDC client and user
134 keycloak.succeed(
135 "curl -sSf -H @admin_auth_header -X POST -H 'Content-Type: application/json' -d @${realmDataJson} '${frontendUrl}/admin/realms/'"
136 )
137
138 # Generate and save the client secret. To do this we need
139 # Keycloak's internal id for the client.
140 keycloak.succeed(
141 "curl -sSf -H @admin_auth_header '${frontendUrl}/admin/realms/${realm.realm}/clients?clientId=${client.name}' | jq -r '.[].id' >client_id",
142 "curl -sSf -H @admin_auth_header -X POST '${frontendUrl}/admin/realms/${realm.realm}/clients/'$(<client_id)'/client-secret' | jq -r .value >client_secret",
143 )
144
145
146 ### Authentication Testing ###
147
148 # Start the login process by sending an initial request to the
149 # OIDC authentication endpoint, saving the returned page. Tidy
150 # up the HTML (XmlStarlet is picky) and extract the login form
151 # post url.
152 keycloak.succeed(
153 "curl -sSf -c cookie '${frontendUrl}/realms/${realm.realm}/protocol/openid-connect/auth?client_id=${client.name}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob&scope=openid+email&response_type=code&response_mode=query&nonce=qw4o89g3qqm' >login_form",
154 "tidy -asxml -q -m login_form || true",
155 "xml sel -T -t -m \"_:html/_:body/_:div/_:div/_:div/_:div/_:div/_:div/_:form[@id='kc-form-login']\" -v @action login_form >form_post_url",
156 )
157
158 # Post the login form and save the response. Once again tidy up
159 # the HTML, then extract the authorization code.
160 keycloak.succeed(
161 "curl -sSf -L -b cookie -d 'username=${user.username}' -d 'password=${password}' -d 'credentialId=' \"$(<form_post_url)\" >auth_code_html",
162 "tidy -asxml -q -m auth_code_html || true",
163 "xml sel -T -t -m \"_:html/_:body/_:div/_:div/_:div/_:div/_:div/_:input[@id='code']\" -v @value auth_code_html >auth_code",
164 )
165
166 # Exchange the authorization code for an access token.
167 keycloak.succeed(
168 "curl -sSf -d grant_type=authorization_code -d code=$(<auth_code) -d client_id=${client.name} -d client_secret=$(<client_secret) -d redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob '${frontendUrl}/realms/${realm.realm}/protocol/openid-connect/token' | jq -r '\"Authorization: bearer \" + .access_token' >auth_header"
169 )
170
171 # Use the access token on the OIDC userinfo endpoint and check
172 # that the returned user info matches what we initialized the
173 # realm with.
174 keycloak.succeed(
175 "curl -sSf -H @auth_header '${frontendUrl}/realms/${realm.realm}/protocol/openid-connect/userinfo' | jq -f ${jqCheckUserinfo}"
176 )
177 '';
178 }
179 );
180in
181{
182 postgres = keycloakTest "postgresql";
183 mariadb = keycloakTest "mariadb";
184 mysql = keycloakTest "mysql";
185}