* @copyright Copyright (c) 2022 Prajwol Amatya prajwol@jankaritech.com * * This code is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, * as published by the Free Software Foundation; * either version 3 of the License, or any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see * */ namespace TestHelpers; require_once __DIR__ . '/../../../vendor-php/autoload.php'; use Exception; use GuzzleHttp\Exception\GuzzleException; use InvalidArgumentException; use JsonException; use OTPHP\TOTP; use ParagonIE\ConstantTime\Base32; use Psr\Http\Message\ResponseInterface; /** * A helper class for Keycloak admin API requests. */ class KeycloakHelper { private const OCIS_KEYCLOAK_USER_ROLES = [ 'Admin' => 'ocisAdmin', 'Space Admin' => 'ocisSpaceAdmin', 'User' => 'ocisUser', 'User Light' => 'ocisGuest', ]; private const OCIS_KEYCLOAK_USER_ROLE_IDS = [ 'ocisAdmin' => '0bb40fa2-4490-4687-9159-b1d27ec7423a', 'ocisSpaceAdmin' => 'bd5f5012-48bb-4ea4-bfe6-0623e3ca0552', 'ocisUser' => '8c79ff81-c256-48fd-b0b9-795c7941eedf', 'ocisGuest' => '7eedfa6d-a2d9-4296-b6db-e75e4e9c0963', 'offline_access' => 'e2145b30-bf6f-49fb-af3f-1b40168bfcef', ]; // Longest known step sequence in getCode()'s MFA loop, with one spare step of headroom: // password POST -> TOTP setup form -> submit -> intermediate redirect -> GET-follow -> // OTP challenge form -> submit -> final redirect (7 steps), so 8 never truncates a real // flow while still bounding it against an infinite loop. private const MAX_MFA_FLOW_STEPS = 8; private static ?string $adminAccessToken = null; private static ?int $adminAccessTokenExpiry = null; /** * @return bool */ public static function isTestingWithKeycloak(): bool { return (\getenv('KEYCLOAK') === "true"); } /** * @return string */ public static function getKeycloakUrl(): string { $keycloakUrl = \getenv('KC_URL'); if ($keycloakUrl !== false && $keycloakUrl !== '') { return $keycloakUrl; } return 'https://keycloak.owncloud.test'; } /** * @param string $accessToken * * @return void */ public static function setAdminAccessToken(string $accessToken): void { self::$adminAccessToken = $accessToken; self::$adminAccessTokenExpiry = self::extractExpiry($accessToken); } /** * Reset the cached admin access token * * @return void */ public static function resetAdminAccessToken(): void { self::$adminAccessToken = null; self::$adminAccessTokenExpiry = null; } /** * Decodes the "exp" claim from a JWT without verifying its signature - this token was just * issued by Keycloak itself, so it is trusted here purely to know when to refresh it. * * @param string $jwt * * @return int|null */ private static function extractExpiry(string $jwt): ?int { $segments = \explode('.', $jwt); if (\count($segments) !== 3) { return null; } $payload = \strtr($segments[1], '-_', '+/'); $payload .= \str_repeat('=', (4 - \strlen($payload) % 4) % 4); $decoded = \json_decode((string)\base64_decode($payload), true); return \is_array($decoded) && isset($decoded['exp']) ? (int)$decoded['exp'] : null; } /** * @return string * @throws GuzzleException */ public static function getAdminAccessToken(): string { // refresh a bit before the actual expiry so a token that's barely valid doesn't get used // for a request that then takes a few seconds to reach the server. This is only a // first line of defense - the real safety net is the retry-on-401 wrapped around every // admin API call below, since a scenario that restarts oCIS can take an unpredictable // amount of time depending on the environment (a single k8s pod's rolling restart vs. a // full single-binary process restart) $expiringSoon = self::$adminAccessTokenExpiry !== null && self::$adminAccessTokenExpiry - 10 < \time(); if (self::$adminAccessToken === null || $expiringSoon) { self::setAdminAccessToken(self::generateAdminAccessToken()); } return (string)self::$adminAccessToken; } /** * @return array * @throws GuzzleException */ private static function getAuthorizationHeader(): array { return [ 'Authorization' => 'Bearer ' . self::getAdminAccessToken() ]; } /** * Runs an admin API request, retrying once with a freshly-generated token if the first * attempt comes back 401. This is the real safety net against a stale cached token - the * proactive expiry check in getAdminAccessToken() is just a first line of defense, since how * long a config-change scenario takes (and therefore whether the cached token survives it) * varies by environment in a way that a fixed margin cannot reliably predict. * * @param callable(array): ResponseInterface $sendRequest * * @return ResponseInterface * @throws GuzzleException */ private static function sendAdminRequest(callable $sendRequest): ResponseInterface { $response = $sendRequest(self::getAuthorizationHeader()); if ($response->getStatusCode() === 401) { self::resetAdminAccessToken(); $response = $sendRequest(self::getAuthorizationHeader()); } return $response; } /** * @param string $roleName * * @return array */ private static function getRealmRole(string $roleName): array { $roleId = self::OCIS_KEYCLOAK_USER_ROLE_IDS[$roleName] ?? throw new InvalidArgumentException("Unknown Keycloak role: {$roleName}"); return [ 'id' => $roleId, 'name' => $roleName, ]; } /** * @param string $username * @param string $password * @param string|null $email * @param string|null $displayName * * @return ResponseInterface * @throws Exception * @throws GuzzleException */ public static function createUser( string $username, string $password, ?string $email = null, ?string $displayName = null, ): ResponseInterface { $url = self::getKeycloakUrl() . '/admin/realms/oCIS/users'; $body = self::prepareCreateUserPayload($username, $password, $email, $displayName); return self::sendAdminRequest( static fn (array $authHeader): ResponseInterface => HttpRequestHelper::post( $url, null, null, array_merge($authHeader, [ 'Content-Type' => 'application/json' ]), $body, ), ); } /** * @param string $uuid * @param string $role * * @return ResponseInterface * @throws GuzzleException * @throws JsonException */ public static function assignRole( string $uuid, string $role, ): ResponseInterface { $url = self::getKeycloakUrl() . "/admin/realms/oCIS/users/" . $uuid . "/role-mappings/realm"; $ocisRole = self::OCIS_KEYCLOAK_USER_ROLES[$role] ?? throw new InvalidArgumentException("Unknown oCIS role: {$role}"); $body = [ self::getRealmRole($ocisRole), self::getRealmRole('offline_access'), ]; $encodedBody = json_encode($body, JSON_THROW_ON_ERROR); return self::sendAdminRequest( static fn (array $authHeader): ResponseInterface => HttpRequestHelper::post( $url, null, null, array_merge($authHeader, [ 'Content-Type' => 'application/json' ]), $encodedBody, ), ); } /** * @param string $uuid * @param string $role * * @return ResponseInterface * @throws GuzzleException * @throws JsonException */ public static function unassignRole( string $uuid, string $role, ): ResponseInterface { $url = self::getKeycloakUrl() . "/admin/realms/oCIS/users/" . $uuid . "/role-mappings/realm"; $ocisRole = self::OCIS_KEYCLOAK_USER_ROLES[$role] ?? throw new InvalidArgumentException("Unknown oCIS role: $role"); $body = [ self::getRealmRole($ocisRole), ]; $encodedBody = json_encode($body, JSON_THROW_ON_ERROR); return self::sendAdminRequest( static fn (array $authHeader): ResponseInterface => HttpRequestHelper::delete( $url, null, null, array_merge($authHeader, [ 'Content-Type' => 'application/json' ]), $encodedBody, ), ); } /** * @return string * @throws Exception * @throws GuzzleException */ private static function generateAdminAccessToken(): string { $url = self::getKeycloakUrl() . '/realms/master/protocol/openid-connect/token'; $response = HttpRequestHelper::post( $url, null, null, ['Content-Type' => 'application/x-www-form-urlencoded'], [ 'client_id' => 'admin-cli', 'username' => 'admin', 'password' => 'admin', 'grant_type' => 'password', ], ); if ($response->getStatusCode() >= 400) { throw new Exception( __METHOD__ . ' failed to get Keycloak admin access token, status ' . $response->getStatusCode() . ', response ' . (string)$response->getBody(), ); } $decodedResponse = HttpRequestHelper::getJsonDecodedResponseBodyContent($response); if (!isset($decodedResponse->access_token)) { throw new Exception(__METHOD__ . ' could not find access_token in Keycloak token response'); } return (string)$decodedResponse->access_token; } /** * @param string $username * @param string $password * @param string|null $email * @param string|null $displayName * * @return string * @throws JsonException */ private static function prepareCreateUserPayload( string $username, string $password, ?string $email = null, ?string $displayName = null, ): string { $firstName = $username; $lastName = ''; if ($displayName !== null && \trim($displayName) !== '') { $nameParts = \preg_split('/\s+/', \trim($displayName), 2); if ($nameParts !== false && isset($nameParts[0])) { $firstName = $nameParts[0]; } if ($nameParts !== false && isset($nameParts[1])) { $lastName = $nameParts[1]; } } $payload = [ 'username' => $username, 'credentials' => [[ 'value' => $password, 'type' => 'password', ]], 'firstName' => $firstName, 'lastName' => $lastName, 'emailVerified' => true, 'enabled' => true, ]; if ($email !== null) { $payload['email'] = $email; } return \json_encode($payload, JSON_THROW_ON_ERROR); } /** * @param string|null $acrValues * * @return array * @throws GuzzleException */ public static function getAuthorizationEndPoint(?string $acrValues = null): array { $loginParams = [ 'client_id' => 'web', 'redirect_uri' => OcisHelper::getServerUrl() . '/oidc-callback.html', 'response_mode' => 'query', 'response_type' => 'code', 'scope' => 'openid profile email acr', ]; if ($acrValues !== null) { $loginParams['acr_values'] = $acrValues; } $queryString = \http_build_query($loginParams); $authUrl = self::getKeycloakUrl() . "/realms/oCIS/protocol/openid-connect/auth?" . $queryString; $response = HttpRequestHelper::get( $authUrl, ); $cookie = $response->getHeader("Set-Cookie")[0]; $htmlData = $response->getBody()->getContents(); if (!preg_match('/action="([^"]+)"/i', $htmlData, $match)) { throw new Exception('No authorization url found in the HTML response body.'); } $authorizationUrl = $match[1]; return [$authorizationUrl, $cookie]; } /** * @param array $user * @param string $authorizationUrl * @param string $cookie * * @return string * @throws GuzzleException * @throws Exception */ public static function getCode( array $user, string $authorizationUrl, string $cookie, ): string { $response = HttpRequestHelper::post( $authorizationUrl, null, null, [ 'Cookie' => $cookie, ], [ 'username' => $user['actualUsername'], 'password' => $user['password'], ], ); // Whatever step-up Keycloak asks for next (first-time "configure TOTP", since this // user has no credential yet, or a login challenge against one that already exists) is // handled here purely over HTTP, mirroring what a browser driving the same flow would do. // Keycloak's required-action flow follows Post/Redirect/Get: after accepting a form // submission it redirects back to its own login-actions endpoint (to be GET-followed) // rather than straight to our redirect_uri, so a Location header alone doesn't mean the // flow is finished - only a redirect that actually targets redirect_uri does. $redirectUriPrefix = OcisHelper::getServerUrl() . '/oidc-callback.html'; $stepsTaken = []; for ($i = 0; $i < self::MAX_MFA_FLOW_STEPS; $i++) { $locationHeader = $response->getHeader('Location'); if (!empty($locationHeader)) { $location = $locationHeader[0]; if (\str_starts_with($location, $redirectUriPrefix)) { return self::extractCodeFromLocationHeader($location); } $stepsTaken[] = 'redirect: ' . $location; $cookie = $response->getHeader('Set-Cookie')[0] ?? $cookie; $response = HttpRequestHelper::get($location, null, null, [ 'Cookie' => $cookie ]); continue; } $html = $response->getBody()->getContents(); // Keycloak may rotate the session cookie at every step of the flow $cookie = $response->getHeader('Set-Cookie')[0] ?? $cookie; if (\preg_match('/id="kc-totp-settings-form"/i', $html)) { $stepsTaken[] = 'kc-totp-settings-form'; [$formUrl, $fields] = self::parseForm($html, 'kc-totp-settings-form'); $rawSecret = $fields['totpSecret'] ?? null; if ($rawSecret === null) { throw new Exception('Could not find the "totpSecret" hidden field on the TOTP setup form.'); } $knownTotpSecret = Base32::encodeUnpadded($rawSecret); $fields['totp'] = TOTP::createFromSecret($knownTotpSecret)->now(); $fields['userLabel'] = 'test'; } else { throw new Exception( 'Unexpected response after username/password submission - no redirect, and ' . 'neither a TOTP setup nor a TOTP login form was found. Status: ' . $response->getStatusCode() . ', body: ' . $html, ); } $response = HttpRequestHelper::post($formUrl, null, null, [ 'Cookie' => $cookie ], $fields); } throw new Exception( 'Too many MFA steps without reaching a redirect to redirect_uri. Steps taken: ' . \implode(' -> ', $stepsTaken) . '. Last response status: ' . $response->getStatusCode() . ', body: ' . $response->getBody()->getContents(), ); } /** * Parses the named form out of the given HTML, returning its action url and every field it * carries (name/value pairs from every /