├── .gitignore ├── LICENSE ├── README.md ├── composer.json └── src └── KeyCloak ├── Grant.php ├── KeyCloak.php └── Token.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /build/ 3 | composer.lock 4 | composer.phar 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Hsiwezeerb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KeyCloak-PHP 2 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "onioniot/keycloak-php", 3 | "description": "Authentication library for interacting with a Keycloak server.", 4 | "keywords": ["keycloak", "authentication", "oauth"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Boken Lin", 9 | "email": "bl@onion.io" 10 | } 11 | ], 12 | "minimum-stability": "stable", 13 | "autoload": { 14 | "psr-4": { 15 | "OnionIoT\\KeyCloak\\": "src/KeyCloak" 16 | } 17 | }, 18 | "require": { 19 | "php": ">5.3.0" 20 | } 21 | } -------------------------------------------------------------------------------- /src/KeyCloak/Grant.php: -------------------------------------------------------------------------------- 1 | _raw = $grant_data; 35 | $grant_data = json_decode($this->_raw, TRUE); 36 | } else { 37 | $this->_raw = json_encode($grant_data); 38 | } 39 | 40 | $this->client_id = array_key_exists('client_id', $grant_data) ? $grant_data['client_id'] : ''; 41 | 42 | $this->access_token = array_key_exists('access_token', $grant_data) ? new Token($grant_data['access_token'], $this->client_id) : NULL; 43 | $this->refresh_token = array_key_exists('refresh_token', $grant_data) ? new Token($grant_data['refresh_token'], $this->client_id) : NULL; 44 | $this->id_token = array_key_exists('id_token', $grant_data) ? new Token($grant_data['id_token'], $this->client_id) : NULL; 45 | 46 | $this->token_type = array_key_exists('token_type', $grant_data) ? $grant_data['token_type'] : 'bearer'; 47 | $this->expires_in = array_key_exists('expires_in', $grant_data) ? $grant_data['expires_in'] : 300; 48 | } 49 | 50 | /** 51 | * Returns the raw String of the grant, if available. 52 | * 53 | * If the raw string is unavailable (due to programatic construction) 54 | * then `undefined` is returned. 55 | */ 56 | public function to_string () { 57 | return $this->_raw; 58 | } 59 | 60 | /** 61 | * Determine if this grant is expired/out-of-date. 62 | * 63 | * Determination is made based upon the expiration status of the `access_token`. 64 | * 65 | * An expired grant *may* be possible to refresh, if a valid 66 | * `refresh_token` is available. 67 | * 68 | * @return {boolean} `true` if expired, otherwise `false`. 69 | */ 70 | public function is_expired () { 71 | if (!$this->access_token) { 72 | return TRUE; 73 | } 74 | 75 | return $this->access_token->is_expired(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/KeyCloak/KeyCloak.php: -------------------------------------------------------------------------------- 1 | realm_id = $config_data['realm']; 35 | 36 | /** 37 | * Client/Application ID 38 | * @type {String} 39 | */ 40 | $this->client_id = array_key_exists('resource', $config_data) ? $config_data['resource'] : $config_data['client_id']; 41 | 42 | /** 43 | * If this is a public application or confidential. 44 | * @type {String} 45 | */ 46 | $this->is_public = array_key_exists('public-client', $config_data) ? $config_data['public-client'] : FALSE; 47 | 48 | /** 49 | * Client/Application secret 50 | * @type {String} 51 | */ 52 | if (!$this->is_public) { 53 | $this->secret = array_key_exists('credentials', $config_data) ? $config_data['credentials']['secret'] : (array_key_exists('secret', $config_data) ? $config_data['secret'] : NULL); 54 | } 55 | 56 | /** 57 | * Authentication server URL 58 | * @type {String} 59 | */ 60 | $auth_server_url = $config_data['auth-server-url'] ? $config_data['auth-server-url'] : 'http://localhost'; 61 | 62 | /** 63 | * Root realm URL. 64 | * @type {String} 65 | */ 66 | $this->realm_url = $auth_server_url . '/realms/' . $this->realm_id; 67 | 68 | /** 69 | * Root realm admin URL. 70 | * @type {String} 71 | */ 72 | $this->realm_admin_url = $auth_server_url . '/admin/realms/' . $this->realm_id; 73 | 74 | /** 75 | * Formatted public-key. 76 | * @type {String} 77 | */ 78 | $key_parts = str_split($config_data['realm-public-key'], 64); 79 | $this->public_key = "-----BEGIN PUBLIC KEY-----\n" . implode("\n", $key_parts) . "\n-----END PUBLIC KEY-----\n"; 80 | } 81 | 82 | /** 83 | * Use the direct grant API to obtain a grant from Keycloak. 84 | * 85 | * The direct grant API must be enabled for the configured realm 86 | * for this method to work. This function ostensibly provides a 87 | * non-interactive, programatic way to login to a Keycloak realm. 88 | * 89 | * This method can either accept a callback as the last parameter 90 | * or return a promise. 91 | * 92 | * @param {String} $username The username. 93 | * @param {String} $password The cleartext password. 94 | * 95 | * @return {Boolean} TRUE for success or FALSE for failure 96 | */ 97 | public function grant_from_login ($username, $password) { 98 | $params = array( 99 | 'grant_type' => 'password', 100 | 'username' => $username, 101 | 'password' => $password 102 | ); 103 | 104 | $headers = array( 105 | 'Content-type: application/x-www-form-urlencoded' 106 | ); 107 | 108 | if ($this->is_public) { 109 | $params['client_id'] = $this->client_id; 110 | } else { 111 | array_push($headers, 'Authorization: Basic ' . base64_encode($this->client_id . ':' . $this->secret)); 112 | } 113 | 114 | $response = $this->send_request('POST', '/protocol/openid-connect/token', $headers, http_build_query($params)); 115 | 116 | if ($response['code'] < 200 || $response['code'] > 299) { 117 | return FALSE; 118 | } else { 119 | $this->grant = new Grant($response['body']); 120 | return TRUE; 121 | } 122 | } 123 | 124 | /** 125 | * Obtain a grant from a previous interactive login which results in a code. 126 | * 127 | * This is typically used by servers which receive the code through a 128 | * redirect_uri when sending a user to Keycloak for an interactive login. 129 | * 130 | * An optional session ID and host may be provided if there is desire for 131 | * Keycloak to be aware of this information. They may be used by Keycloak 132 | * when session invalidation is triggered from the Keycloak console itself 133 | * during its postbacks to `/k_logout` on the server. 134 | * 135 | * This method returns or promise or may optionally take a callback function. 136 | * 137 | * @param {String} $code The code from a successful login redirected from Keycloak. 138 | * @param {String} $session_id Optional opaque session-id. 139 | * @param {String} $session_host Optional session host for targetted Keycloak console post-backs. 140 | * 141 | * @return {Boolean} TRUE for success or FALSE for failure 142 | */ 143 | public function grant_from_code ($code, $redirect_uri = '', $session_host = NULL) { 144 | $params = array( 145 | 'grant_type' => 'authorization_code', 146 | 'code' => $code, 147 | 'client_id' => $this->client_id 148 | ); 149 | 150 | if (!empty($redirect_uri)) { 151 | $params['redirect_uri'] = $redirect_uri; 152 | } 153 | 154 | if ($session_host) { 155 | $params['application_session_host'] = $session_host; 156 | } 157 | 158 | $headers = array( 159 | 'Content-Type: application/x-www-form-urlencoded' 160 | ); 161 | 162 | if ($this->is_public) { 163 | $params['client_id'] = $this->client_id; 164 | } else { 165 | array_push($headers, 'Authorization: Basic ' . base64_encode($this->client_id . ':' . $this->secret)); 166 | } 167 | 168 | $response = $this->send_request('POST', '/protocol/openid-connect/token', $headers, http_build_query($params)); 169 | 170 | // Shit has failed 171 | if ($response['code'] < 200 || $response['code'] > 299) { 172 | return NULL; 173 | } else { 174 | $this->grant = new Grant($response['body']); 175 | return TRUE; 176 | } 177 | } 178 | 179 | /** 180 | * Restore a grant that has been saved in the session. 181 | * 182 | * This is typically used by server after the user has already logged on 183 | * and the grant saved in the session. 184 | * 185 | * This method returns or promise or may optionally take a callback function. 186 | * 187 | * @param {String} $code The code from a successful login redirected from Keycloak. 188 | * @param {String} $session_id Optional opaque session-id. 189 | * @param {String} $session_host Optional session host for targetted Keycloak console post-backs. 190 | * 191 | * @return {Boolean} TRUE for success or FALSE for failure 192 | */ 193 | public function grant_from_data ($grant_data) { 194 | $this->grant = new Grant($grant_data); 195 | 196 | $success = $this->validate_grant(); 197 | 198 | if ($success) { 199 | return TRUE; 200 | } else { 201 | return $this->refresh_grant(); 202 | } 203 | } 204 | 205 | /** 206 | * Ensure that a grant is *fresh*, refreshing if required & possible. 207 | * 208 | * If the access_token is not expired, the grant is left untouched. 209 | * 210 | * If the access_token is expired, and a refresh_token is available, 211 | * the grant is refreshed, in place (no new object is created), 212 | * and returned. 213 | * 214 | * If the access_token is expired and no refresh_token is available, 215 | * an error is provided. 216 | * 217 | * The method may either return a promise or take an optional callback. 218 | * 219 | * @return {Boolean} TRUE for success or FALSE for failure 220 | */ 221 | protected function refresh_grant () { 222 | // Ensure grant exists, grant is not expired, and we have a refresh token 223 | if (!$this->grant || $this->grant->is_expired() || !$this->grant->refresh_token) { 224 | $this->grant = NULL; 225 | return FALSE; 226 | } 227 | 228 | $params = array( 229 | 'grant_type' => 'refresh_token', 230 | 'refresh_token' => $this->grant->refresh_token->to_string() 231 | ); 232 | 233 | $headers = array( 234 | 'Content-type: application/x-www-form-urlencoded' 235 | ); 236 | 237 | if ($this->is_public) { 238 | $params['client_id'] = $this->client_id; 239 | } else { 240 | array_push($headers, 'Authorization: Basic ' . base64_encode($this->client_id . ':' . $this->secret)); 241 | } 242 | 243 | $response = $this->send_request('POST', '/protocol/openid-connect/token', $headers, http_build_query($params)); 244 | 245 | // Shit has failed 246 | if ($response['code'] < 200 || $response['code'] > 299) { 247 | $this->grant = NULL; 248 | return FALSE; 249 | } else { 250 | $this->grant = new Grant($response['body']); 251 | return TRUE; 252 | } 253 | } 254 | 255 | /** 256 | * Validate the grant and all tokens contained therein. 257 | * 258 | * This method filters a grant (in place), by nulling out 259 | * any invalid tokens. After this method returns, the 260 | * passed in grant will only contain valid tokens. 261 | * 262 | */ 263 | protected function validate_grant () { 264 | $this->grant->access_token = $this->validate_token($this->grant->access_token) ? $this->grant->access_token : NULL; 265 | $this->grant->refresh_token = $this->validate_token($this->grant->refresh_token) ? $this->grant->refresh_token : NULL; 266 | $this->grant->id_token = $this->validate_token($this->grant->id_token) ? $this->grant->id_token : NULL; 267 | 268 | if ($this->grant->access_token && $this->grant->refresh_token && $this->grant->id_token) { 269 | return TRUE; 270 | } else { 271 | return FALSE; 272 | } 273 | } 274 | 275 | /** 276 | * Validate a token. 277 | * 278 | * This method accepts a token, and either returns the 279 | * same token object, if valid, else, it returns `undefined` 280 | * if any of the following errors are seen: 281 | * 282 | * - The token was undefined in the first place. 283 | * - The token is expired. 284 | * - The token is not expired, but issued before the current *not before* timestamp. 285 | * - The token signature does not verify against the known realm public-key. 286 | * @param {Token|String} $token The token to validate. 287 | * @param {Boolean} $remote If validation should be performed against the Keycloak server. 288 | * 289 | * @return {boolean} FALSE if the token is invalid, or TRUE if token is valid. 290 | * 291 | */ 292 | protected function validate_token ($token, $remote = FALSE) { 293 | if (!$token) { 294 | return FALSE; 295 | } 296 | 297 | // Perform a Remote validation against the KeyCloak server 298 | if ($remote) { 299 | $params = array( 300 | 'access_token' => gettype($token) === 'string' ? $token : $token->to_string() 301 | ); 302 | 303 | $path = '/tokens/validate?' . http_build_query($params); 304 | 305 | $response = $this->send_request('GET', $path); 306 | 307 | if ($response['code'] < 200 || $response['code'] > 299) { 308 | return FALSE; 309 | } else { 310 | try { 311 | $data = json_decode($response['body'], TRUE); 312 | } catch (Exception $e) { 313 | return FALSE; 314 | } 315 | 316 | if (array_key_exists('error', $data)) { 317 | return FALSE; 318 | } else { 319 | return TRUE; 320 | } 321 | } 322 | 323 | // Validate token signature locally 324 | } else { 325 | if ($token->is_expired()) { 326 | return FALSE; 327 | } 328 | 329 | $verified = openssl_verify($token->signed, $token->signature, $this->public_key, OPENSSL_ALGO_SHA256); 330 | 331 | if ($verified === 1) { 332 | return TRUE; 333 | } else { 334 | return FALSE; 335 | } 336 | } 337 | } 338 | 339 | /** 340 | * Get the account information associated with the token 341 | * 342 | * This method accepts a token, and either returns the 343 | * user account information, or it returns NULL 344 | * if it encourters error: 345 | * 346 | * @return {Array} An array that contains user account info, or NULL 347 | */ 348 | public function get_account ($remote = FALSE) { 349 | if ($remote) { 350 | $headers = array( 351 | 'Authorization: Bearer ' . $this->grant->access_token->to_string(), 352 | 'Accept: application/json' 353 | ); 354 | 355 | $response = $this->send_request('GET', '/protocol/openid-connect/userinfo', $headers); 356 | 357 | if ($response['code'] < 200 || $response['code'] > 299) { 358 | return NULL; 359 | } else { 360 | try { 361 | $data = json_decode($response['body'], TRUE); 362 | } catch (Exception $e) { 363 | return NULL; 364 | } 365 | 366 | if (array_key_exists('error', $data)) { 367 | return NULL; 368 | } else { 369 | return $data; 370 | } 371 | } 372 | 373 | } else { 374 | if ($this->grant) { 375 | $user = $this->grant->access_token->payload; 376 | return array( 377 | 'name' => $user['name'], 378 | 'username' => $user['preferred_username'], 379 | 'first_name' => $user['given_name'], 380 | 'last_name' => $user['family_name'], 381 | 'email' => $user['email'] 382 | ); 383 | } else { 384 | return NULL; 385 | } 386 | } 387 | } 388 | 389 | /** 390 | * Various URL getters 391 | **/ 392 | public function login_url ($redirect_uri) { 393 | $uuid = bin2hex(openssl_random_pseudo_bytes(32)); 394 | 395 | return $this->realm_url . '/protocol/openid-connect/auth?client_id=' . KeyCloak::encode_uri_component($this->client_id) . '&state=' . KeyCloak::encode_uri_component($uuid) . '&redirect_uri=' . KeyCloak::encode_uri_component($redirect_uri) . '&response_type=code'; 396 | } 397 | 398 | public function logout_url ($redirect_uri) { 399 | return $this->realm_url . '/protocol/openid-connect/logout?redirect_uri=' . KeyCloak::encode_uri_component($redirect_uri); 400 | } 401 | 402 | public function account_url ($redirect_uri) { 403 | return $this->realm_url . '/account' . '?referrer=' . KeyCloak::encode_uri_component($this->client_id) . '&referrer_uri=' . KeyCloak::encode_uri_component($redirect_uri); 404 | } 405 | 406 | /** 407 | * Send HTTP request via CURL 408 | * 409 | * @param {String} $method The HTTP request to use. (Default to GET) 410 | * @param {String} $path The path that follows $this->realm_url, can include GET params 411 | * @param {Array} $headers The HTTP headers to be passed into the request 412 | * @param {String} $data The data to be passed into the body of the request 413 | * 414 | * @return {Array} An associative array with 'code' for response code and 'body' for request body 415 | */ 416 | protected function send_request ($method = 'GET', $path = '/', $headers = array(), $data = '') { 417 | $method = strtoupper($method); 418 | $url = $this->realm_url . $path; 419 | 420 | // Initiate HTTP request 421 | $request = curl_init(); 422 | 423 | curl_setopt($request, CURLOPT_URL, $url); 424 | curl_setopt($request, CURLOPT_RETURNTRANSFER, TRUE); 425 | 426 | if ($method === 'POST') { 427 | curl_setopt($request, CURLOPT_POST, TRUE); 428 | curl_setopt($request, CURLOPT_POSTFIELDS, $data); 429 | array_push($headers, 'Content-Length: ' . strlen($data)); 430 | } 431 | 432 | curl_setopt($request, CURLOPT_HTTPHEADER, $headers); 433 | $response = curl_exec($request); 434 | $response_code = curl_getinfo($request, CURLINFO_HTTP_CODE); 435 | curl_close($request); 436 | 437 | return array( 438 | 'code' => $response_code, 439 | 'body' => $response 440 | ); 441 | } 442 | 443 | /** 444 | * PHP version of Javascript's encodeURIComponent that doesn't covert every character 445 | * 446 | * @param {String} $str The string to be encoded. 447 | */ 448 | public static function encode_uri_component ($str) { 449 | $revert = array( 450 | '%21' => '!', 451 | '%2A' => '*', 452 | '%27' => "'", 453 | '%28' => '(', 454 | '%29' => ')' 455 | ); 456 | return strtr(rawurlencode($str), $revert); 457 | } 458 | 459 | /** 460 | * Decode a string with URL-safe Base64. 461 | * 462 | * @param string $input A Base64 encoded string 463 | * 464 | * @return string A decoded string 465 | */ 466 | public static function url_base64_decode ($input) { 467 | $remainder = strlen($input) % 4; 468 | if ($remainder) { 469 | $padlen = 4 - $remainder; 470 | $input .= str_repeat('=', $padlen); 471 | } 472 | return base64_decode(strtr($input, '-_', '+/')); 473 | } 474 | 475 | /** 476 | * Encode a string with URL-safe Base64. 477 | * 478 | * @param string $input The string you want encoded 479 | * 480 | * @return string The base64 encode of what you passed in 481 | */ 482 | public static function url_base64_encode ($input) { 483 | return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); 484 | } 485 | } -------------------------------------------------------------------------------- /src/KeyCloak/Token.php: -------------------------------------------------------------------------------- 1 | _raw = $token_str; 28 | $this->client_id = $client_id; 29 | 30 | if ($token_str) { 31 | try { 32 | $parts = explode('.', $token_str); 33 | 34 | $this->header = json_decode(KeyCloak::url_base64_decode($parts[0]), TRUE); 35 | $this->payload = json_decode(KeyCloak::url_base64_decode($parts[1]), TRUE); 36 | $this->signature = KeyCloak::url_base64_decode($parts[2]); 37 | $this->signed = $parts[0] . '.' . $parts[1]; 38 | } catch (Exception $e) { 39 | $this->payload = array( 40 | 'expires_at' => 0 41 | ); 42 | } 43 | } 44 | } 45 | 46 | /** 47 | * Returns the raw String of the grant, if available. 48 | * 49 | * If the raw string is unavailable (due to programatic construction) 50 | * then `undefined` is returned. 51 | */ 52 | public function to_string () { 53 | return $this->_raw; 54 | } 55 | 56 | /** 57 | * Determine if this token is expired. 58 | * 59 | * @return {boolean} `true` if it is expired, otherwise `false`. 60 | */ 61 | public function is_expired () { 62 | $current_time = time(); 63 | 64 | if ($this->payload['exp'] < $current_time || $this->payload['iat'] < $current_time - 86400) { 65 | return TRUE; 66 | } else { 67 | return FALSE; 68 | } 69 | } 70 | 71 | /** 72 | * Determine if this token has an associated role. 73 | * 74 | * This method is only functional if the token is constructed 75 | * with a `clientId` parameter. 76 | * 77 | * The parameter matches a role specification using the following rules: 78 | * 79 | * - If the name contains no colons, then the name is taken as the entire 80 | * name of a role within the current application, as specified via 81 | * `clientId`. 82 | * - If the name starts with the literal `realm:`, the subsequent portion 83 | * is taken as the name of a _realm-level_ role. 84 | * - Otherwise, the name is split at the colon, with the first portion being 85 | * taken as the name of an arbitrary application, and the subsequent portion 86 | * as the name of a role with that app. 87 | * 88 | * @param {String} $name The role name specifier. 89 | * 90 | * @return {boolean} `true` if this token has the specified role, otherwise `false`. 91 | */ 92 | public function has_role ($name) { 93 | if (!$this->client_id) { 94 | return FALSE; 95 | } 96 | 97 | $parts = explode(':', $name); 98 | 99 | if (count($parts) === 1) { 100 | return $this->has_application_role($this->client_id, $parts[0]); 101 | } 102 | 103 | if ($parts[0] === 'realm') { 104 | return $this->has_realm_role($parts[1]); 105 | } 106 | 107 | return $this->has_application_role($parts[0], $parts[1]); 108 | } 109 | 110 | /** 111 | * Determine if this token has an associated specific application role. 112 | * 113 | * Even if `clientId` is not set, this method may be used to explicitly test 114 | * roles for any given application. 115 | * 116 | * @param {String} $app_name The identifier of the application to test. 117 | * @param {String} $role_name The name of the role within that application to test. 118 | * 119 | * @return {boolean} `true` if this token has the specified role, otherwise `false`. 120 | */ 121 | public function has_application_role ($app_name, $role_name) { 122 | $app_roles = $this->payload['resource_access'][appName]; 123 | 124 | if (!$app_roles) { 125 | return FALSE; 126 | } 127 | 128 | return (array_search($role_name, $app_roles['roles']) ? TRUE : FALSE); 129 | } 130 | 131 | /** 132 | * Determine if this token has an associated specific realm-level role. 133 | * 134 | * Even if `clientId` is not set, this method may be used to explicitly test 135 | * roles for the realm. 136 | * 137 | * @param {String} $app_name The identifier of the application to test. 138 | * @param {String} $role_name The name of the role within that application to test. 139 | * 140 | * @return {boolean} `true` if this token has the specified role, otherwise `false`. 141 | */ 142 | public function has_realm_role ($role_name) { 143 | return (array_search($role_name, $this->payload['realm_access']['roles']) ? TRUE : FALSE); 144 | } 145 | } 146 | 147 | --------------------------------------------------------------------------------