Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
bshaffer committed Feb 13, 2024
1 parent a8768b6 commit 4d9a3b9
Show file tree
Hide file tree
Showing 5 changed files with 174 additions and 58 deletions.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"phpspec/prophecy-phpunit": "^2.0",
"sebastian/comparator": ">=1.2.3",
"phpseclib/phpseclib": "^3.0",
"kelvinmo/simplejwt": "0.7.1"
"kelvinmo/simplejwt": "0.7.1",
"symfony/process": "^7.0"
},
"suggest": {
"phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2."
Expand Down
143 changes: 118 additions & 25 deletions src/CredentialSource/ExecutableSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,52 @@
use RuntimeException;

/**
* Retrieve a token from an executable.
* 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,
Expand All @@ -35,38 +80,32 @@ class ExecutableSource implements ExternalAccountCredentialSourceInterface
* The default executable timeout when none is provided, in milliseconds.
*/
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';

private string $command;
private ExecutableHandler $executableHandler;
private ?string $outputFile;
private array $environmentVariables;

/**
* @param string $executable The string executable to run to get the subject token.
* @param int $timeoutMillis
* @param string $command The string command to run to get the subject token.
* @param string $outputFile
* @param array<string, string> $environmentVariables
*/
public function __construct(
string $command,
?string $outputFile,
ExecutableHandler $executableHandler = null,
) {
$this->executable = $executable;
$this->command = $command;
$this->outputFile = $outputFile;
$this->executableHandler = $executableHandler ?: new ExecutableHandler();
}

/**
* @param callable $httpHandler unused.
* @param callable $executableHandler A function which returns the output of the command with
* the following function signature:
* function (string $command, array $envVars, int &$returnVar = null): string
* @param callable $httpHandler unused.
*/
public function fetchSubjectToken(
callable $httpHandler = null,
callable $executableHandler = null
): string {
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(
Expand All @@ -85,24 +124,19 @@ public function fetchSubjectToken(
}

// Run the executable.
$returnVar = null;
$cmdOutput = ($this->executableHandler)($this->executable, $returnVar);
$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 ($returnVar !== 0) {
if ($exitCode !== 0) {
throw new RuntimeException(
'The executable failed to run'
. ($cmdOutput ? ' with the following error: ' . $cmdOutput : '.')
. ($output ? ' with the following error: ' . $output : '.'),
$exitCode
);
}

// If the exit code is 0 and there's a response, return the output as the subject token.
if ($cmdOutput) {
$json = json_decode($cmdOutput, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $json['id_token'];
}
}
return $this->parseTokenFromResponse($output);

if ($this->outputFile && $fileContents = file_get_contents($this->outputFile)) {
json_decode($fileContents);
Expand All @@ -114,4 +148,63 @@ public function fetchSubjectToken(

throw new RuntimeException('Unable to retrieve a token from the executable.');
}

private function parseTokenFromResponse(string $responseJson): string
{
$json = json_decode($token, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new UnexpectedValueException('The executable response is not valid JSON.');
}
if (empty($json['version'])) {
throw new UnexpectedValueException('Executable response must contain a "version" field.');
}
if (empty($json['success'])) {
throw new UnexpectedValueException('Executable response must contain a "success" field.');
}

// Validate required fields for a successful response.
if ($json['success']) {
// Validate token type field.
$tokenTypes = [self::SAML_SUBJECT_TOKEN_TYPE, self::OIDC_SUBJECT_TOKEN_TYPE1, self::OIDC_SUBJECT_TOKEN_TYPE2];
if (!in_array($json['token_type'], $tokenTypes)) {
throw new UnexpectedValueException(sprintf(
'Executable response must contain a "token_type" field when successful and it'
. ' must be one of %s.',
implode(', ', $tokenTypes)
));
}

// Validate subject token.
if ($json['token_type'] === self::SAML_SUBJECT_TOKEN_TYPE) {
if (empty($json['saml_response'])) {
throw new UnexpectedValueException(sprintf(
'Executable response must contain a "saml_response" field when token_type=%s.',
self::SAML_SUBJECT_TOKEN_TYPE
));
}
return $json['saml_response'];
}

if (empty($json['id_token'])) {
throw new UnexpectedValueException(sprintf(
'Executable response must contain a "id_token" field when '
. 'token_type=%s or %s.',
self::OIDC_SUBJECT_TOKEN_TYPE1,
self::OIDC_SUBJECT_TOKEN_TYPE2
));
}

return $json['id_token'];
}

// Both code and message must be provided for unsuccessful responses.
if (empty($json['code'])) {
throw new UnexpectedValueException('Executable response must contain a "code" field when unsuccessful.');
}
if (empty($json['message'])) {
throw new UnexpectedValueException('Executable response must contain a "message" field when unsuccessful.');
}

throw new UnexpectedValueException($json['message'], $json['code']);
}
}
14 changes: 9 additions & 5 deletions src/Credentials/ExternalAccountCredentials.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Google\Auth\CredentialSource\ExecutableSource;
use Google\Auth\CredentialSource\FileSource;
use Google\Auth\CredentialSource\UrlSource;
use Google\Auth\ExecutableHandler\ExecutableHandler;
use Google\Auth\ExternalAccountCredentialSourceInterface;
use Google\Auth\FetchAuthTokenInterface;
use Google\Auth\GetQuotaProjectInterface;
Expand All @@ -33,6 +34,7 @@
use Google\Auth\UpdateMetadataTrait;
use GuzzleHttp\Psr7\Request;
use InvalidArgumentException;
use Symfony\Component\Process\Process;

class ExternalAccountCredentials implements
FetchAuthTokenInterface,
Expand Down Expand Up @@ -178,30 +180,32 @@ private static function buildCredentialSource(array $jsonKey): ExternalAccountCr
}

// Build command environment variables
$envVars = [
$env = [
'GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE' => $jsonKey['audience'],
'GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE' => $jsonKey['subject_token_type'],
// Always set to 0 because interactive mode is not supported.
'GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE' => '0',

];
if ($outputFile = $credentialSource['executable']['output_file'] ?? null) {
$envVars['GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE'] = $outputFile;
$env['GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE'] = $outputFile;
}
if ($serviceAccountImpersonationUrl = $jsonKey['service_account_impersonation_url'] ?? null) {
// Parse email from URL. The formal looks as follows:
// https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken
$regex = '/serviceAccounts\/(?<email>[^:]+):generateAccessToken$/';
if (preg_match($regex, $serviceAccountImpersonationUrl, $matches)) {
$envVars['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $matches['email'];
$env['GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL'] = $matches['email'];
}
}

return new ExecutableSource(
$credentialSource['executable']['command'],
$credentialSource['executable']['timeout_millis'] ?? null,
$outputFile,
$envVars,
new ExecutableHandler(
$credentialSource['executable']['timeout_millis'] ?? null,
$env
)
);
}

Expand Down
53 changes: 36 additions & 17 deletions src/ExecutableHandler/ExecutableHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,35 +16,54 @@
*/
namespace Google\Auth\ExecutableHandler;

use Symfony\Component\Process\Process;

class ExecutableHandler
{
private const DEFAULT_EXECUTABLE_TIMEOUT_MILLIS = 30 * 1000;

private int $timeout;
private array $envVars;
private int $timeoutMs;
private array $env = [];
private ?string $output = null;
private ?string $errorCode = null;
private ?string $errorMessage = null;

public function __construct(
int $timeout = self::DEFAULT_EXECUTABLE_TIMEOUT_MILLIS,
array $envVars = []
int $timeoutMs = self::DEFAULT_EXECUTABLE_TIMEOUT_MILLIS,
array $env = []
) {
$this->timeout = $timeout;
$this->envVars = $envVars;
if (!class_exists(Process::class)) {
throw new RuntimeException(
'The "symfony/process" package is required to use the ProcessExecutableHandler.'
);
}
$this->timeoutMs = $timeoutMs;
$this->env = $env;
}

/**
* @param string $command
* @param int|null $returnVar
* @return int
*/
public function __invoke(string $command, ?int &$returnVar): string
public function __invoke(string $command): int
{
$process = Process::fromShellCommandline(
$command,
null,
$this->env,
null,
($this->timeoutMs / 1000)
);

$process->run();

$this->output = $process->getOutput();

return $process->getExitCode();
}

public function getOutput(): ?string
{
$envVarString = implode(' ', array_map(
fn ($key, $value) => "$key=$value",
array_keys($this->envVars),
$this->envVars
));
$command = escapeshellcmd($envVarString . ' ' . $command);
exec($command, $output, $returnVar);

return implode("\n", $output);
return $this->output;
}
}
19 changes: 9 additions & 10 deletions tests/CredentialSource/ExecutableSourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
namespace Google\Auth\Tests\CredentialSource;

use Google\Auth\CredentialSource\ExecutableSource;
use Google\Auth\ExecutableHandler\ExecutableHandler;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
Expand Down Expand Up @@ -59,16 +60,14 @@ public function testNoAllowExecutableEnvVarThrowsException()
public function testFetchSubjectToken(string $expectedCommand)
{
putenv('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1');
$source = new ExecutableSource($expectedCommand, null, null);
$subjectToken = $source->fetchSubjectToken(
null,
function (string $command, array $envVars, &$returnCode) use ($expectedCommand) {
$this->assertEquals($expectedCommand, $command);
$this->assertEquals([], $envVars);
$returnCode = 0;
return '{"access_token": "abc"}';
}
);

$returnVar = null;
$executableHandler = $this->prophesize(ExecutableHandler::class);
$executableHandler->__invoke($expectedCommand, $returnVar)
->willReturn('{"access_token": "abc"}');

$source = new ExecutableSource($expectedCommand, null, $executableHandler->reveal());
$subjectToken = $source->fetchSubjectToken();
$this->assertEquals('{"access_token": "abc"}', $subjectToken);
}

Expand Down

0 comments on commit 4d9a3b9

Please sign in to comment.