KeyCloak::get_last_refresh_time()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 1
eloc 1
c 2
b 1
f 0
nc 1
nop 0
dl 0
loc 2
rs 10
1
<?php
2
3
/*
4
 * SPDX-License-Identifier: AGPL-3.0-only
5
 * SPDX-FileCopyrightText: Copyright 2023 grommunio GmbH
6
 *
7
 * Performs several actions against a KeyCloak server.
8
 */
9
10
// include the token grant class
11
require_once 'class.token.php';
12
13
class KeyCloak {
14
	public $access_token;
15
	public $refresh_token;
16
	public $id_token;
17
	public $redirect_url;
18
19
	public $last_refresh_time;
20
	private static $_instance;
21
	public $grant;
22
	public $error;
23
24
	protected $realm_id;
25
	protected $client_id;
26
	protected $secret;
27
28
	protected $realm_url;
29
	protected $realm_admin_url;
30
31
	protected $public_key;
32
	protected $is_public;
33
34
	/**
35
	 * The constructor reads all required values from the KeyCloak configuration file.
36
	 * This includes values for realm_id, client_id, client_secret, server_url etc.
37
	 */
38
	public function __construct(mixed $keycloak_config) {
39
		if (is_string($keycloak_config)) {
40
			$keycloak_config = json_decode($keycloak_config, true);
41
		}
42
43
		// redirect_url
44
		$url = "https://" . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
45
		$url_exp = explode('?', $url);
46
		$url = $url_exp[0];
47
		if (str_ends_with($url, '/')) {
48
			$url = substr($url, 0, -1);
49
		}
50
		$this->redirect_url = $url;
51
52
		// keycloak Realm ID
53
		$this->realm_id = $keycloak_config['realm'] ?? 'grommunio';
54
55
		// keycloak client ID
56
		$this->client_id = $keycloak_config['resource'] ?? 'gramm';
57
58
		// @type {bool} checks if client is a public client and extracts the public key
59
		$this->is_public = $keycloak_config['public-client'] ?? false;
60
		$this->public_key = $this->is_public == false ? "" : "-----BEGIN PUBLIC KEY-----\n" . chunk_split((string) $keycloak_config['realm-public-key'], 64, "\n") . "\n-----END PUBLIC KEY-----\n";
61
62
		// client secret => obtained if client is not a public client
63
		if (!$this->is_public) {
64
			$this->secret = $keycloak_config['credentials']['secret'] ?? $keycloak_config['secret'] ?? null;
65
		}
66
67
		// keycloak server url
68
		$auth_server_url = $keycloak_config['auth-server-url'] ?? 'null';
69
70
		// Root realm URL.
71
		$this->realm_url = $auth_server_url . 'realms/' . $this->realm_id;
72
73
		// Root realm admin URL.
74
		$this->realm_admin_url = $auth_server_url . 'admin/realms/' . $this->realm_id;
75
	}
76
77
	/**
78
	 * Static method to instantiate and return a KeyCloak instance from the
79
	 * default configuration file.
80
	 */
81
	public static function getInstance(): ?KeyCloak {
82
		if (!defined('GROMOX_CONFIG_PATH')) {
83
			define('GROMOX_CONFIG_PATH', '/etc/gromox/');
84
		}
85
		if (KeyCloak::$_instance === null && file_exists(GROMOX_CONFIG_PATH . 'keycloak.json')) {
86
			// Read the keycloak config adapter into an instance of the keyclaok class
87
			$keycloak_file = file_get_contents(GROMOX_CONFIG_PATH . 'keycloak.json');
88
			$keycloak_json = json_decode($keycloak_file, true);
89
			KeyCloak::$_instance = new KeyCloak($keycloak_json);
90
		}
91
92
		return KeyCloak::$_instance;
93
	}
94
95
	/**
96
	 * Returns the last known refresh time.
97
	 */
98
	public function get_last_refresh_time(): ?int {
99
		return $this->last_refresh_time;
100
	}
101
102
	/**
103
	 * Sets  the last refresh time.
104
	 */
105
	public function set_last_refresh_time(int $time): void {
106
		$this->last_refresh_time = $time;
107
	}
108
109
	/**
110
	 * Oauth 2.0 Authorization flow is used to obtain Access token,
111
	 * refresh token and ID token from keycloak server by sending
112
	 * https post request (curl) to a /token web endpoint.
113
	 * The keycloak server will respond with grant back to the
114
	 * grommunio server.
115
	 *
116
	 * We implement three of this protocol:
117
	 *  1. OAuth 2.0 resource owner password credential grant.
118
	 *  2. OAuth 2.0 Client Code credential grant.
119
	 *  3. Refresh token grant.
120
	 *
121
	 * The password grant takes two argument:
122
	 *
123
	 * @param string $username The username
124
	 * @param string $password The cleartext password
125
	 *
126
	 * @return bool indicating if the request was successful nor not
127
	 */
128
	public function password_grant_req(string $username, string $password): bool {
129
		$params = ['grant_type' => 'password', 'username' => $username, 'password' => $password];
130
131
		return $this->request($params);
132
	}
133
134
	/**
135
	 * The Oauth 2.0 client credential code grant is the next type request used to
136
	 * request access token from keycloak. The logon on the Authentication server url
137
	 * (keycloak), on successful authentication. the server replies with the credential
138
	 * grant code. This code will be used to request the tokens.
139
	 *
140
	 * @param string $code The code from a successful login redirected from Keycloak
141
	 *
142
	 * @return bool indicating if the request was successful nor not
143
	 */
144
	public function client_credential_grant_req(string $code): bool {
145
		$params = ['grant_type' => 'authorization_code', 'code' => $code, 'client_id' => $this->client_id, 'redirect_uri' => $this->redirect_url];
146
147
		return $this->request($params);
148
	}
149
150
	/**
151
	 * The Oauth 2.0 refresh token grant is the next type request used to
152
	 * request access token from keycloak. If the client has a valid refresh token
153
	 * which has not expired. It can send a request to the server to obtain new tokens.
154
	 *
155
	 * @return bool indicating if the request was successful nor not
156
	 */
157
	public function refresh_grant_req(): bool {
158
		// Ensure grant exists, grant is not expired, and we have a refresh token
159
		if (!$this->grant || !$this->refresh_token) {
160
			$this->grant = null;
161
162
			return false;
163
		}
164
		$params = ['grant_type' => 'refresh_token', 'refresh_token' => $this->refresh_token->get_payload()];
165
166
		return $this->request($params);
167
	}
168
169
	/**
170
	 * Performs a token request to the KeyCloak server with predefined parameters.
171
	 *
172
	 * @param array $params predefined parameters used for the request
173
	 *
174
	 * @return bool indicating if the request was successful or not
175
	 */
176
	protected function request(array $params): bool {
177
		$headers = ['Content-Type: application/x-www-form-urlencoded'];
178
		if ($this->is_public) {
179
			$params['client_id'] = $this->client_id;
180
		}
181
		else {
182
			$headers[] = 'Authorization: Basic ' . base64_encode($this->client_id . ':' . $this->secret);
183
		}
184
		$params['scope'] = 'openid';
185
		$response = $this->http_curl_request('POST', '/protocol/openid-connect/token', $headers, http_build_query($params));
186
		if ($response['code'] < 200 || $response['code'] > 299) {
187
			$this->error = $response['body'];
188
			$this->grant = null;
189
190
			return false;
191
		}
192
		$this->grant = $response['body'];
193
		if (is_string($this->grant)) {
194
			$this->grant = json_decode($this->grant, true);
195
		}
196
		else {
197
			$this->grant = json_encode($this->grant);
198
		}
199
		$this->access_token = isset($this->grant['access_token']) ? new Token($this->grant['access_token']) : null;
200
		$this->refresh_token = isset($this->grant['refresh_token']) ? new Token($this->grant['refresh_token']) : null;
201
		$this->id_token = isset($this->grant['id_token']) ? new Token($this->grant['id_token']) : null;
202
203
		return true;
204
	}
205
206
	/**
207
	 * Validates the grant represented by the access and refresh tokens in the grant.
208
	 * If the refresh token has expired too, return false.
209
	 */
210
	public function validate_grant(): bool {
211
		return ($this->validate_token($this->access_token) && $this->validate_token($this->refresh_token)) ||
212
			$this->refresh_grant_req();
213
	}
214
215
	/**
216
	 * Validates a token with the server.
217
	 */
218
	protected function validate_token(mixed $token): bool {
219
		if (!isset($token)) {
220
			return false;
221
		}
222
223
		$path = "/protocol/openid-connect/token/introspect";
224
		$headers = ['Content-Type: application/x-www-form-urlencoded'];
225
		$params = ['token' => $token->get_payload()];
226
		if ($this->is_public) {
227
			$params['client_id'] = $this->client_id;
228
		}
229
		else {
230
			$headers[] = 'Authorization: Basic ' . base64_encode($this->client_id . ':' . $this->secret);
231
		}
232
		$response = $this->http_curl_request('POST', $path, $headers, http_build_query($params));
233
234
		if ($response['code'] < 200 || $response['code'] > 299) {
235
			return false;
236
		}
237
238
		try {
239
			$data = json_decode((string) $response['body'], true);
240
		}
241
		catch (Exception) {
242
			return false;
243
		}
244
245
		return !array_key_exists('error', $data);
246
	}
247
248
	/**
249
	 * Indicates if the access token is expired.
250
	 */
251
	public function is_expired(): bool {
252
		return !$this->access_token || $this->access_token->is_expired();
253
	}
254
255
	/**
256
	 * Builds a KeyCloak login url used with the client credential code.
257
	 *
258
	 * @param string $redirect_url Redirect URL to be parameterized in the URL
259
	 */
260
	public function login_url(string $redirect_url): string {
261
		$uuid = bin2hex(openssl_random_pseudo_bytes(32));
262
263
		return $this->realm_url . '/protocol/openid-connect/auth?scope=openid&client_id=' . urlencode((string) $this->client_id) . '&state=' . urlencode($uuid) . '&redirect_uri=' . urlencode($redirect_url) . '&response_type=code';
264
	}
265
266
	/**
267
	 * Builds a KeyCloak logout url.
268
	 */
269
	public function logout(): string {
270
		$params = '?id_token_hint=' . $this->id_token->get_payload() . '&refresh_token=' . $this->refresh_token->get_payload();
271
272
		return $this->realm_url . '/protocol/openid-connect/logout' . $params;
273
	}
274
275
	/*
276
	 * Send HTTP request via CURL.
277
	 *
278
	 * @param string $method The HTTP request to use. (Default to GET)
279
	 * @param array $headers The HTTP headers to be passed into the request
280
	 * @param string $data The data to be passed into the body of the request
281
	 * @param string $domain
282
	 *
283
	 * @return array associative array with 'code' for response code and 'body' for request body
284
	 */
285
	protected function http_curl_request(string $method, string $domain, array $headers = [], string $data = ''): array {
286
		$request = curl_init();
287
		curl_setopt($request, CURLOPT_URL, $this->realm_url . $domain);
288
		if (strcasecmp($method, 'POST') === 0) {
289
			curl_setopt($request, CURLOPT_POST, true);
290
			curl_setopt($request, CURLOPT_POSTFIELDS, $data);
291
			$headers[] = 'Content-Length: ' . strlen((string) $data);
292
		}
293
294
		curl_setopt($request, CURLOPT_HTTPHEADER, $headers);
295
		curl_setopt($request, CURLOPT_RETURNTRANSFER, true);
296
297
		$response = curl_exec($request);
298
		$response_code = curl_getinfo($request, CURLINFO_HTTP_CODE);
299
		curl_close($request);
300
301
		return [
302
			'code' => $response_code,
303
			'body' => $response,
304
		];
305
	}
306
}
307