Your IP : 216.73.216.79


Current Path : /var/www/html/kpwkm/common/components/
Upload File :
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;
    }
}