<?php

/*
 * Copyright 2024 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
namespace Matomo\Dependencies\GoogleAnalyticsImporter\Google\Auth\CredentialSource;

use Matomo\Dependencies\GoogleAnalyticsImporter\Google\Auth\ExecutableHandler\ExecutableHandler;
use Matomo\Dependencies\GoogleAnalyticsImporter\Google\Auth\ExecutableHandler\ExecutableResponseError;
use Matomo\Dependencies\GoogleAnalyticsImporter\Google\Auth\ExternalAccountCredentialSourceInterface;
use RuntimeException;
/**
 * ExecutableSource enables the exchange of workload identity pool external credentials for
 * Google access tokens by retrieving 3rd party tokens through a user supplied executable. These
 * scripts/executables are completely independent of the Google Cloud Auth libraries. These
 * credentials plug into ADC and will call the specified executable to retrieve the 3rd party token
 * to be exchanged for a Google access token.
 *
 * To use these credentials, the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable
 * must be set to '1'. This is for security reasons.
 *
 * Both OIDC and SAML are supported. The executable must adhere to a specific response format
 * defined below.
 *
 * The executable must print out the 3rd party token to STDOUT in JSON format. When an
 * output_file is specified in the credential configuration, the executable must also handle writing the
 * JSON response to this file.
 *
 * <pre>
 * OIDC response sample:
 * {
 *   "version": 1,
 *   "success": true,
 *   "token_type": "urn:ietf:params:oauth:token-type:id_token",
 *   "id_token": "HEADER.PAYLOAD.SIGNATURE",
 *   "expiration_time": 1620433341
 * }
 *
 * SAML2 response sample:
 * {
 *   "version": 1,
 *   "success": true,
 *   "token_type": "urn:ietf:params:oauth:token-type:saml2",
 *   "saml_response": "...",
 *   "expiration_time": 1620433341
 * }
 *
 * Error response sample:
 * {
 *   "version": 1,
 *   "success": false,
 *   "code": "401",
 *   "message": "Error message."
 * }
 * </pre>
 *
 * The "expiration_time" field in the JSON response is only required for successful
 * responses when an output file was specified in the credential configuration
 *
 * The auth libraries will populate certain environment variables that will be accessible by the
 * executable, such as: GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE, GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE,
 * GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE, GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL, and
 * GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE.
 */
class ExecutableSource implements ExternalAccountCredentialSourceInterface
{
    private const GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES = 'GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES';
    private const SAML_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:saml2';
    private const OIDC_SUBJECT_TOKEN_TYPE1 = 'urn:ietf:params:oauth:token-type:id_token';
    private const OIDC_SUBJECT_TOKEN_TYPE2 = 'urn:ietf:params:oauth:token-type:jwt';
    /**
     * @var string
     */
    private $command;
    /**
     * @var \Matomo\Dependencies\GoogleAnalyticsImporter\Google\Auth\ExecutableHandler\ExecutableHandler
     */
    private $executableHandler;
    /**
     * @var string|null
     */
    private $outputFile;
    /**
     * @param string $command    The string command to run to get the subject token.
     * @param string $outputFile
     */
    public function __construct(string $command, ?string $outputFile, ExecutableHandler $executableHandler = null)
    {
        $this->command = $command;
        $this->outputFile = $outputFile;
        $this->executableHandler = $executableHandler ?: new ExecutableHandler();
    }
    /**
     * Gets the unique key for caching
     * The format for the cache key is:
     * Command.OutputFile
     *
     * @return ?string
     */
    public function getCacheKey() : ?string
    {
        return $this->command . '.' . $this->outputFile;
    }
    /**
     * @param callable $httpHandler unused.
     * @return string
     * @throws RuntimeException if the executable is not allowed to run.
     * @throws ExecutableResponseError if the executable response is invalid.
     */
    public function fetchSubjectToken(callable $httpHandler = null) : string
    {
        // Check if the executable is allowed to run.
        if (getenv(self::GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES) !== '1') {
            throw new RuntimeException('Pluggable Auth executables need to be explicitly allowed to run by ' . 'setting the GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment ' . 'Variable to 1.');
        }
        if (!($executableResponse = $this->getCachedExecutableResponse())) {
            // Run the executable.
            $exitCode = ($this->executableHandler)($this->command);
            $output = $this->executableHandler->getOutput();
            // If the exit code is not 0, throw an exception with the output as the error details
            if ($exitCode !== 0) {
                throw new ExecutableResponseError('The executable failed to run' . ($output ? ' with the following error: ' . $output : '.'), (string) $exitCode);
            }
            $executableResponse = $this->parseExecutableResponse($output);
            // Validate expiration.
            if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) {
                throw new ExecutableResponseError('Executable response is expired.');
            }
        }
        // Throw error when the request was unsuccessful
        if ($executableResponse['success'] === \false) {
            throw new ExecutableResponseError($executableResponse['message'], (string) $executableResponse['code']);
        }
        // Return subject token field based on the token type
        return $executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE ? $executableResponse['saml_response'] : $executableResponse['id_token'];
    }
    /**
     * @return array<string, mixed>|null
     */
    private function getCachedExecutableResponse() : ?array
    {
        if ($this->outputFile && file_exists($this->outputFile) && !empty(trim($outputFileContents = (string) file_get_contents($this->outputFile)))) {
            try {
                $executableResponse = $this->parseExecutableResponse($outputFileContents);
            } catch (ExecutableResponseError $e) {
                throw new ExecutableResponseError('Error in output file: ' . $e->getMessage(), 'INVALID_OUTPUT_FILE');
            }
            if ($executableResponse['success'] === \false) {
                // If the cached token was unsuccessful, run the executable to get a new one.
                return null;
            }
            if (isset($executableResponse['expiration_time']) && time() >= $executableResponse['expiration_time']) {
                // If the cached token is expired, run the executable to get a new one.
                return null;
            }
            return $executableResponse;
        }
        return null;
    }
    /**
     * @return array<string, mixed>
     */
    private function parseExecutableResponse(string $response) : array
    {
        $executableResponse = json_decode($response, \true);
        if (json_last_error() !== \JSON_ERROR_NONE) {
            throw new ExecutableResponseError('The executable returned an invalid response: ' . $response, 'INVALID_RESPONSE');
        }
        if (!array_key_exists('version', $executableResponse)) {
            throw new ExecutableResponseError('Executable response must contain a "version" field.');
        }
        if (!array_key_exists('success', $executableResponse)) {
            throw new ExecutableResponseError('Executable response must contain a "success" field.');
        }
        // Validate required fields for a successful response.
        if ($executableResponse['success']) {
            // Validate token type field.
            $tokenTypes = [self::SAML_SUBJECT_TOKEN_TYPE, self::OIDC_SUBJECT_TOKEN_TYPE1, self::OIDC_SUBJECT_TOKEN_TYPE2];
            if (!isset($executableResponse['token_type'])) {
                throw new ExecutableResponseError('Executable response must contain a "token_type" field when successful');
            }
            if (!in_array($executableResponse['token_type'], $tokenTypes)) {
                throw new ExecutableResponseError(sprintf('Executable response "token_type" field must be one of %s.', implode(', ', $tokenTypes)));
            }
            // Validate subject token for SAML and OIDC.
            if ($executableResponse['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE) {
                if (empty($executableResponse['saml_response'])) {
                    throw new ExecutableResponseError(sprintf('Executable response must contain a "saml_response" field when token_type=%s.', self::SAML_SUBJECT_TOKEN_TYPE));
                }
            } elseif (empty($executableResponse['id_token'])) {
                throw new ExecutableResponseError(sprintf('Executable response must contain a "id_token" field when ' . 'token_type=%s.', $executableResponse['token_type']));
            }
            // Validate expiration exists when an output file is specified.
            if ($this->outputFile) {
                if (!isset($executableResponse['expiration_time'])) {
                    throw new ExecutableResponseError('The executable response must contain a "expiration_time" field for successful responses ' . 'when an output_file has been specified in the configuration.');
                }
            }
        } else {
            // Both code and message must be provided for unsuccessful responses.
            if (!array_key_exists('code', $executableResponse)) {
                throw new ExecutableResponseError('Executable response must contain a "code" field when unsuccessful.');
            }
            if (empty($executableResponse['message'])) {
                throw new ExecutableResponseError('Executable response must contain a "message" field when unsuccessful.');
            }
        }
        return $executableResponse;
    }
}
