| Current Path : /var/www/html/kpwkm/common/components/ |
| Current File : /var/www/html/kpwkm/common/components/OpenIDConnectAuth.php |
<?php
namespace common\components;
use Jumbojett\OpenIDConnectClient;
use Jumbojett\OpenIDConnectClientException;
use Yii;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use yii\base\Component;
use yii\base\InvalidConfigException;
/**
* OpenIDConnectAuth handles OpenID Connect authentication via Keycloak (or any OIDC-compatible provider).
*
* Configuration example in `params.php`:
* ```php
* 'openID' => [
* 'BASE_URL' => 'https://auth.example.com',
* 'ID' => 'my-client-id',
* 'SECRET' => 'my-client-secret',
* 'REALM' => 'my-realm',
* 'REDIRECT_URL' => 'https://myapp.com/auth/callback',
* ]
* ```
*
* Usage:
* ```php
* $auth = new OpenIDConnectAuth();
* $auth->login();
*
* // In your callback action:
* $token = $auth->getAccessToken(Yii::$app->request->get('code'));
* $userInfo = $auth->getUserInfo($token);
* ```
*/
class OpenIDConnectAuth extends Component
{
/** @var string Base URL of the OIDC provider (e.g. https://auth.example.com) */
public string $BASE_URL;
/** @var string Client secret issued by the OIDC provider */
public string $SECRET;
/** @var string Realm name (Keycloak-specific concept) */
public string $REALM;
/** @var string Client ID issued by the OIDC provider */
public string $ID;
/** @var string Redirect URI registered with the OIDC provider */
public string $REDIRECT_URL;
/** @var int HTTP request timeout in seconds */
private int $httpTimeout = 10;
/** @var string[] Required configuration keys */
private const REQUIRED_KEYS = ['BASE_URL', 'ID', 'SECRET', 'REALM', 'REDIRECT_URL'];
/**
* @throws InvalidConfigException if any required OpenID configuration key is missing or empty
*/
public function __construct()
{
parent::__construct();
$this->loadConfig();
}
/**
* Loads and validates OpenID Connect configuration from Yii application params.
*
* @throws InvalidConfigException
*/
private function loadConfig(): void
{
$config = Yii::$app->params['openID'] ?? null;
if (!is_array($config)) {
throw new InvalidConfigException(
"Missing 'openID' configuration block in application params. " .
"Please define it in your params.php or params-local.php."
);
}
foreach (self::REQUIRED_KEYS as $key) {
$value = $config[$key] ?? null;
if ($value === null || trim((string)$value) === '') {
throw new InvalidConfigException(
"OpenID configuration key '{$key}' is missing or empty. " .
"Please set it in params['openID']['{$key}']."
);
}
$this->{$key} = $value;
}
// Strip trailing slash to prevent double-slash URLs
$this->BASE_URL = rtrim($this->BASE_URL, '/');
}
/**
* Returns the base realm URL used for all OIDC endpoints.
*/
private function getRealmUrl(): string
{
return "{$this->BASE_URL}/realms/{$this->REALM}";
}
/**
* Initiates the OIDC authentication flow by redirecting the user to the provider's login page.
*
* This method triggers a redirect and does not return under normal circumstances.
* Ensure session handling is available before calling this.
*
* @throws OpenIDConnectClientException if the authentication flow fails
* @throws \Exception for unexpected errors during OIDC setup
*/
public function login(): void
{
try {
$oidc = new OpenIDConnectClient(
$this->getRealmUrl(),
$this->ID,
$this->SECRET
);
$oidc->setRedirectURL($this->REDIRECT_URL);
$oidc->authenticate();
} catch (OpenIDConnectClientException $e) {
Yii::error("OIDC login failed: " . $e->getMessage(), __METHOD__);
throw $e;
} catch (\Exception $e) {
Yii::error("Unexpected error during OIDC login: " . $e->getMessage(), __METHOD__);
throw $e;
}
}
/**
* Exchanges an authorization code for an access token.
*
* This should be called in your redirect/callback action after the provider
* redirects back with a `code` query parameter.
*
* @param string $code The authorization code from the OIDC provider callback
* @return string The access token string
*
* @throws \InvalidArgumentException if the authorization code is empty
* @throws \RuntimeException if the token endpoint does not return an access token
* @throws \Exception on HTTP or JSON decoding failures
*/
public function getAccessToken(string $code): \stdClass
{
if (trim($code) === '') {
throw new \InvalidArgumentException('Authorization code must not be empty.');
}
$endpoint = $this->getRealmUrl() . '/protocol/openid-connect/token';
$body = $this->post($endpoint, [
'client_id' => $this->ID,
'client_secret' => $this->SECRET,
'redirect_uri' => $this->REDIRECT_URL,
'grant_type' => 'authorization_code',
'code' => $code,
]);
if (empty($body->access_token)) {
Yii::error('Token endpoint returned no access_token. Response: ' . json_encode($body), __METHOD__);
throw new \RuntimeException('Failed to retrieve access token from authorization server.');
}
return $body;
}
/**
* Retrieves user information from the OIDC userinfo endpoint using a valid access token.
*
* @param string $token A valid Bearer access token
* @return \stdClass User info object (fields depend on scopes granted)
*
* @throws \InvalidArgumentException if the token is empty
* @throws \RuntimeException if the userinfo response is empty or malformed
* @throws \Exception on HTTP failures
*/
public function getUserInfo(string $token): \stdClass
{
if (trim($token) === '') {
throw new \InvalidArgumentException('Access token must not be empty.');
}
$endpoint = $this->getRealmUrl() . '/protocol/openid-connect/userinfo';
$client = $this->makeHttpClient();
try {
$response = $client->request('GET', $endpoint, [
'headers' => ['Authorization' => 'Bearer ' . $token],
]);
} catch (ConnectException $e) {
Yii::error("Could not connect to userinfo endpoint [{$endpoint}]: " . $e->getMessage(), __METHOD__);
throw new \RuntimeException('Unable to connect to the authorization server.', 0, $e);
} catch (RequestException $e) {
Yii::error("HTTP error fetching userinfo: " . $e->getMessage(), __METHOD__);
throw new \RuntimeException('Failed to retrieve user information.', 0, $e);
}
$body = $this->parseJsonResponse($response, $endpoint);
// The 'sub' claim is required by the OIDC spec and always present on success
if (empty($body->sub)) {
throw new \RuntimeException('Userinfo response is missing the required "sub" claim.');
}
return $body;
}
/**
* Sends a POST request with form parameters and returns the decoded JSON body.
*
* @param string $url Full URL of the endpoint
* @param array $params Form parameters to send
* @return \stdClass Decoded response body
*
* @throws \RuntimeException on connection or HTTP errors
*/
private function post(string $url, array $params): \stdClass
{
$client = $this->makeHttpClient();
try {
$response = $client->request('POST', $url, ['form_params' => $params]);
} catch (ConnectException $e) {
Yii::error("Could not connect to endpoint [{$url}]: " . $e->getMessage(), __METHOD__);
throw new \RuntimeException('Unable to connect to the authorization server.', 0, $e);
} catch (RequestException $e) {
$statusCode = $e->hasResponse() ? $e->getResponse()->getStatusCode() : 'N/A';
Yii::error("HTTP {$statusCode} error posting to [{$url}]: " . $e->getMessage(), __METHOD__);
throw new \RuntimeException('Authorization server returned an error response.', 0, $e);
}
return $this->parseJsonResponse($response, $url);
}
/**
* Creates and returns a configured Guzzle HTTP client instance.
*
* @return Client
*/
private function makeHttpClient(): Client
{
return new Client([
'timeout' => $this->httpTimeout,
'connect_timeout' => 5,
'http_errors' => true, // Let Guzzle throw on 4xx/5xx so we can catch RequestException
]);
}
/**
* Validates an HTTP response and decodes its JSON body.
*
* @param \Psr\Http\Message\ResponseInterface $response The HTTP response
* @param string $endpoint URL (used for logging context)
* @return \stdClass Decoded response body
*
* @throws \RuntimeException if the status code is not 200 or the body is not valid JSON
*/
private function parseJsonResponse($response, string $endpoint): \stdClass
{
$statusCode = $response->getStatusCode();
if ($statusCode !== 200) {
Yii::error(
"Unexpected status {$statusCode} from endpoint [{$endpoint}]. " .
"Body: " . $response->getBody(),
__METHOD__
);
throw new \RuntimeException(
"Authorization server responded with HTTP {$statusCode}."
);
}
$rawBody = (string)$response->getBody();
if (empty($rawBody)) {
throw new \RuntimeException("Empty response body received from [{$endpoint}].");
}
$decoded = json_decode($rawBody);
if (json_last_error() !== JSON_ERROR_NONE) {
Yii::error("Failed to decode JSON from [{$endpoint}]: " . json_last_error_msg(), __METHOD__);
throw new \RuntimeException('Failed to parse response from authorization server.');
}
return $decoded;
}
}