* @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 /