1
0
mirror of https://github.com/owncloud/ocis.git synced 2026-10-23 23:29:02 +03:00
Files
ocis/tests/acceptance/TestHelpers/KeycloakHelper.php
Prajwol Amatya 3e3bb41132 [tests-only][full-ci] test: set up Keycloak users via HTTP instead of Web UI (#12930)
test: fix user mfa setup for vauld

Signed-off-by: Prajwol Amatya <prajwolamatya11@gmail.com>
2026-09-16 12:12:55 +05:45

731 lines
22 KiB
PHP

<?php declare(strict_types=1);
/**
* ownCloud
*
* @author Prajwol Amatya <prajwol@jankaritech.com>
* @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 <http://www.gnu.org/licenses/>
*
*/
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<string, string>): 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 <input>/<button>, including hidden ones such as
* "credentialId" or "totpSecret") - a real browser submission sends all of them, and
* Keycloak's authenticators can silently re-render the same form (200, no redirect) if a
* required hidden field is missing from the submission.
*
* @param string $html
* @param string $formId
*
* @return array
* @throws Exception
*/
private static function parseForm(string $html, string $formId): array {
// Capture the opening tag's attributes as one group so "id" and "action" can be pulled
// out independent of which order Keycloak's template happens to render them in.
if (!preg_match(
'/<form\b([^>]*\bid="' . preg_quote($formId, '/') . '"[^>]*)>(.*?)<\/form>/is',
$html,
$formMatch,
)
) {
throw new Exception("Could not find the \"$formId\" form in the HTML response body.");
}
$openingTagAttrs = $formMatch[1];
$formBody = $formMatch[2];
if (!preg_match('/\baction="([^"]+)"/i', $openingTagAttrs, $actionMatch)) {
throw new Exception("No action url found on the \"$formId\" form.");
}
$formUrl = \html_entity_decode($actionMatch[1]);
$fields = [];
if (preg_match_all('/<(?:input|button)\b[^>]*>/i', $formBody, $fieldMatches)) {
foreach ($fieldMatches[0] as $fieldTag) {
if (!preg_match('/\bname="([^"]+)"/i', $fieldTag, $nameMatch)) {
continue;
}
$value = '';
if (preg_match('/\bvalue="([^"]*)"/i', $fieldTag, $valueMatch)) {
$value = \html_entity_decode($valueMatch[1]);
}
$fields[$nameMatch[1]] = $value;
}
}
return [$formUrl, $fields];
}
/**
* @param string $location
*
* @return string
* @throws Exception
*/
private static function extractCodeFromLocationHeader(string $location): string {
$queryString = parse_url($location, PHP_URL_QUERY);
parse_str((string)$queryString, $urlParams);
if (!isset($urlParams['code'])) {
throw new Exception("No 'code' parameter found in redirect location: $location");
}
return $urlParams['code'];
}
/**
* @param string $authorizationCode
*
* @return ResponseInterface
* @throws GuzzleException
* @throws Exception
*/
public static function getToken(string $authorizationCode): ResponseInterface {
$url = self::getKeycloakUrl() . '/realms/oCIS/protocol/openid-connect/token';
$tokenResponse = HttpRequestHelper::post(
$url,
null,
null,
null,
[
'client_id' => 'web',
'code' => $authorizationCode,
'redirect_uri' => OcisHelper::getServerUrl() . '/oidc-callback.html',
'grant_type' => 'authorization_code',
],
);
if ($tokenResponse->getStatusCode() !== 200) {
throw new Exception(
'Failed to retrieve token: Expected status code to be 200 but received ' .
$tokenResponse->getStatusCode() .
'.\nMessage: ' .
$tokenResponse->getBody()->getContents(),
);
}
return $tokenResponse;
}
/**
* @param array $user
* @param string|null $acrValues
*
* @return array
* @throws GuzzleException
* @throws JsonException
* @throws Exception
*/
public static function setAccessTokenForKeycloakOcisUser(
array $user,
?string $acrValues = null,
): array {
[$authorizationUrl, $cookie] = self::getAuthorizationEndPoint($acrValues);
$authorizationCode = self::getCode($user, $authorizationUrl, $cookie);
$tokenResponse = self::getToken($authorizationCode);
return json_decode($tokenResponse->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
}
/**
* @return array
* @throws GuzzleException
* @throws JsonException
* @throws Exception
*/
public static function getRealm(): array {
$url = self::getKeycloakUrl() . '/admin/realms/oCIS';
$response = self::sendAdminRequest(
static fn (array $authHeader): ResponseInterface => HttpRequestHelper::get($url, null, null, $authHeader),
);
if ($response->getStatusCode() !== 200) {
throw new Exception("Failed to get realm roles.");
}
return json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
}
/**
* @param string $key
* @param string $value
*
* @return ResponseInterface
* @throws GuzzleException
* @throws JsonException
*/
public static function updateRealmAttribute(string $key, string $value): ResponseInterface {
$realm = self::getRealm();
$attributes = $realm['attributes'] ?? [];
$attributes[$key] = $value;
$url = self::getKeycloakUrl() . '/admin/realms/oCIS';
$body = json_encode(['attributes' => $attributes], JSON_THROW_ON_ERROR);
return self::sendAdminRequest(
static fn (array $authHeader): ResponseInterface => HttpRequestHelper::put(
$url,
null,
null,
array_merge($authHeader, [ 'Content-Type' => 'application/json' ]),
$body,
),
);
}
/**
* @param string $key
*
* @return ResponseInterface
* @throws GuzzleException
* @throws JsonException
*/
public static function deleteRealmAttribute(string $key): ResponseInterface {
$realm = self::getRealm();
$attributes = $realm['attributes'] ?? [];
unset($attributes[$key]);
$url = self::getKeycloakUrl() . '/admin/realms/oCIS';
$body = json_encode(['attributes' => $attributes], JSON_THROW_ON_ERROR);
return self::sendAdminRequest(
static fn (array $authHeader): ResponseInterface => HttpRequestHelper::put(
$url,
null,
null,
array_merge($authHeader, [ 'Content-Type' => 'application/json' ]),
$body,
),
);
}
/**
* @param string $username
*
* @return string
* @throws GuzzleException
* @throws JsonException
* @throws Exception
*/
public static function getUserIdByUsername(string $username): string {
$url = self::getKeycloakUrl() . '/admin/realms/oCIS/users?username=' . \urlencode($username) . '&exact=true';
$response = self::sendAdminRequest(
static fn (array $authHeader): ResponseInterface => HttpRequestHelper::get($url, null, null, $authHeader),
);
if ($response->getStatusCode() !== 200) {
throw new Exception("Failed to look up Keycloak user '$username', status: " . $response->getStatusCode());
}
$users = json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
if (empty($users)) {
throw new Exception("Keycloak user '$username' not found.");
}
return $users[0]['id'];
}
/**
* Deletes all OTP/TOTP credentials for a Keycloak user, so MFA can be set up fresh.
*
* @param string $username
*
* @return void
* @throws GuzzleException
* @throws JsonException
* @throws Exception
*/
public static function deleteUserTotpCredentials(string $username): void {
$uuid = self::getUserIdByUsername($username);
$url = self::getKeycloakUrl() . '/admin/realms/oCIS/users/' . $uuid . '/credentials';
$response = self::sendAdminRequest(
static fn (array $authHeader): ResponseInterface =>
HttpRequestHelper::get(
$url,
null,
null,
$authHeader,
),
);
if ($response->getStatusCode() !== 200) {
throw new Exception("Failed to list credentials for Keycloak user '$username'.");
}
$credentials = json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
foreach ($credentials as $credential) {
if (isset($credential['type']) && \in_array($credential['type'], ['otp', 'totp'], true)) {
$deleteUrl = self::getKeycloakUrl()
. '/admin/realms/oCIS/users/' . $uuid
. '/credentials/' . $credential['id'];
self::sendAdminRequest(
static fn (array $authHeader): ResponseInterface =>
HttpRequestHelper::delete(
$deleteUrl,
null,
null,
$authHeader,
),
);
}
}
}
/**
* @param string $uuid
*
* @return ResponseInterface
* @throws GuzzleException
*/
public static function deleteKeycloakUser(string $uuid): ResponseInterface {
$url = self::getKeycloakUrl() . '/admin/realms/oCIS/users/' . $uuid;
return self::sendAdminRequest(
static fn (array $authHeader): ResponseInterface =>
HttpRequestHelper::delete(
$url,
null,
null,
$authHeader,
),
);
}
}