Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
bshaffer committed Jun 6, 2024
1 parent 035a6a0 commit d001a7f
Show file tree
Hide file tree
Showing 2 changed files with 40 additions and 131 deletions.
143 changes: 22 additions & 121 deletions src/AccessToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,18 @@
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\CachedKeySet;
use Google\Auth\Cache\MemoryCacheItemPool;
use Google\Auth\HttpHandler\HttpClientCache;
use Google\Auth\HttpHandler\HttpHandlerFactory;
use GuzzleHttp\Psr7\HttpFactory;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Utils;
use InvalidArgumentException;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
use stdClass;
use UnexpectedValueException;
Expand Down Expand Up @@ -111,22 +116,28 @@ public function verify($token, array $options = [])
$audience = $options['audience'] ?? null;
$issuer = $options['issuer'] ?? null;
$certsLocation = $options['certsLocation'] ?? self::FEDERATED_SIGNON_CERT_URL;
$cacheKey = $options['cacheKey'] ?? $this->getCacheKeyFromCertLocation($certsLocation);
$throwException = $options['throwException'] ?? false; // for backwards compatibility

// Check signature against each available cert.
$certs = $this->getCerts($certsLocation, $cacheKey, $options);
try {
$keys = [];
foreach ($certs as $cert) {
if (empty($cert['kid'])) {
throw new InvalidArgumentException('certs expects "kid" to be set');
$keySet = new CachedKeySet(
$certsLocation,
new class($this->httpHandler) implements ClientInterface {
public function __construct(private $httpHandler)

Check failure on line 125 in src/AccessToken.php

View workflow job for this annotation

GitHub Actions / PHPStan Static Analysis

Method class@anonymous/AccessToken.php:124::__construct() has parameter $httpHandler with no type specified.
{
}
// create an array of key IDs to certs for the JWT library
$keys[(string) $cert['kid']] = JWK::parseKey($cert);
}

public function sendRequest(RequestInterface $request): ResponseInterface
{
return ($this->httpHandler)($request);
}
},
new HttpFactory(),
$this->cache
);

try {
$headers = new stdClass();
$payload = ($this->jwt)::decode($token, $keys, $headers);
$payload = ($this->jwt)::decode($token, $keySet, $headers);

if ($audience) {
if (!property_exists($payload, 'aud') || $payload->aud != $audience) {
Expand Down Expand Up @@ -193,114 +204,4 @@ public function revoke($token, array $options = [])

return $response->getStatusCode() == 200;
}

/**
* Gets federated sign-on certificates to use for verifying identity tokens.
* Returns certs as array structure, where keys are key ids, and values
* are PEM encoded certificates.
*
* @param string $location The location from which to retrieve certs.
* @param string $cacheKey The key under which to cache the retrieved certs.
* @param array<mixed> $options [optional] Configuration options.
* @return array<mixed>
* @throws InvalidArgumentException If received certs are in an invalid format.
*/
private function getCerts($location, $cacheKey, array $options = [])
{
$cacheItem = $this->cache->getItem($cacheKey);
$certs = $cacheItem ? $cacheItem->get() : null;

$expireTime = null;
if (!$certs) {
list($certs, $expireTime) = $this->retrieveCertsFromLocation($location, $options);
}

if (!isset($certs['keys'])) {
if ($location !== self::IAP_CERT_URL) {
throw new InvalidArgumentException(
'federated sign-on certs expects "keys" to be set'
);
}
throw new InvalidArgumentException(
'certs expects "keys" to be set'
);
}

// Push caching off until after verifying certs are in a valid format.
// Don't want to cache bad data.
if ($expireTime) {
$cacheItem->expiresAt(new DateTime($expireTime));
$cacheItem->set($certs);
$this->cache->save($cacheItem);
}

return $certs['keys'];
}

/**
* Retrieve and cache a certificates file.
*
* @param string $url location
* @param array<mixed> $options [optional] Configuration options.
* @return array{array<mixed>, string}
* @throws InvalidArgumentException If certs could not be retrieved from a local file.
* @throws RuntimeException If certs could not be retrieved from a remote location.
*/
private function retrieveCertsFromLocation($url, array $options = [])
{
// If we're retrieving a local file, just grab it.
$expireTime = '+1 hour';
if (strpos($url, 'http') !== 0) {
if (!file_exists($url)) {
throw new InvalidArgumentException(sprintf(
'Failed to retrieve verification certificates from path: %s.',
$url
));
}

return [
json_decode((string) file_get_contents($url), true),
$expireTime
];
}

$httpHandler = $this->httpHandler;
$response = $httpHandler(new Request('GET', $url), $options);

if ($response->getStatusCode() == 200) {
if ($cacheControl = $response->getHeaderLine('Cache-Control')) {
array_map(function ($value) use (&$expireTime) {
list($key, $value) = explode('=', $value) + [null, null];
if (trim($key) == 'max-age') {
$expireTime = '+' . $value . ' seconds';
}
}, explode(',', $cacheControl));
}
return [
json_decode((string) $response->getBody(), true),
$expireTime
];
}

throw new RuntimeException(sprintf(
'Failed to retrieve verification certificates: "%s".',
$response->getBody()->getContents()
), $response->getStatusCode());
}

/**
* Generate a cache key based on the cert location using sha1 with the
* exception of using "federated_signon_certs_v3" to preserve BC.
*
* @param string $certsLocation
* @return string
*/
private function getCacheKeyFromCertLocation($certsLocation)
{
$key = $certsLocation === self::FEDERATED_SIGNON_CERT_URL
? 'federated_signon_certs_v3'
: sha1($certsLocation);

return 'google_auth_certs_cache|' . $key;
}
}
28 changes: 18 additions & 10 deletions tests/AccessTokenTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -289,35 +289,43 @@ public function testRetrieveCertsFromLocationLocalFile()
{
$certsLocation = __DIR__ . '/fixtures/federated-certs.json';
$certsData = json_decode(file_get_contents($certsLocation), true);
$kid = null;
foreach ($certsData['keys'] as $i => $cert) {
$certsData[$cert['kid']] = $cert;
$kid = $cert['kid'];
}
unset($certsData['keys']);

$item = $this->prophesize('Psr\Cache\CacheItemInterface');
$item->get()
->shouldBeCalledTimes(1)
->willReturn(null);
$item->isHit()->shouldBeCalledTimes(1)->willReturn(false);
$item->set($certsData)
->shouldBeCalledTimes(1)
->willReturn($item->reveal());
$item->expiresAt(Argument::type('\DateTime'))
->shouldBeCalledTimes(1)
->willReturn($item->reveal());

$this->cache->getItem('google_auth_certs_cache|' . sha1($certsLocation))
$cacheKey = 'jwks' . preg_replace('|[^a-zA-Z0-9_\.!]|', '', $certsLocation);
$cacheKey = substr(hash('sha256', $cacheKey), 0, 64);
$this->cache->getItem($cacheKey)
->shouldBeCalledTimes(1)
->willReturn($item->reveal());

$this->cache->save(Argument::type('Psr\Cache\CacheItemInterface'))
->shouldBeCalledTimes(1);

$jwt = new MockJWT(function ($token, $keys, &$headers) {
$jwt = new MockJWT(function ($token, $keys, &$headers) use ($kid) {
$this->assertEquals($this->token, $token);
$this->assertEquals('RS256', array_pop($keys)->getAlgorithm());
$this->assertArrayHasKey($kid, $keys);
$this->assertEquals('RS256', $keys[$kid]->getAlgorithm());
$headers->alg = 'RS256';

return (object) $this->payload;
});

$token = new AccessToken(
null,
function ($request) {
return new Response(200, [
'cache-control' => 'public, max-age=1000',
], file_get_contents((string)$request->getUri()));
},
$this->cache->reveal(),
$jwt
);
Expand Down

0 comments on commit d001a7f

Please sign in to comment.