Skip to content

Commit

Permalink
Abstract away JWS fetcher
Browse files Browse the repository at this point in the history
  • Loading branch information
cicnavi committed Jan 9, 2025
1 parent c078f83 commit ff2ffe6
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 76 deletions.
1 change: 1 addition & 0 deletions src/Codebooks/ContentTypesEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@

enum ContentTypesEnum: string
{
case ApplicationJwt = 'application/jwt';
case ApplicationEntityStatementJwt = 'application/entity-statement+jwt';
}
2 changes: 1 addition & 1 deletion src/Federation.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ public function jwsSerializerManager(): JwsSerializerManager
public function entityStatementFetcher(): EntityStatementFetcher
{
return $this->entityStatementFetcher ??= new EntityStatementFetcher(
$this->artifactFetcher(),
$this->entityStatementFactory(),
$this->artifactFetcher(),
$this->maxCacheDurationDecorator,
$this->helpers(),
$this->logger,
Expand Down
108 changes: 38 additions & 70 deletions src/Federation/EntityStatementFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,35 @@
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
use SimpleSAML\OpenID\Codebooks\ContentTypesEnum;
use SimpleSAML\OpenID\Codebooks\EntityTypesEnum;
use SimpleSAML\OpenID\Codebooks\HttpHeadersEnum;
use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum;
use SimpleSAML\OpenID\Codebooks\WellKnownEnum;
use SimpleSAML\OpenID\Decorators\CacheDecorator;
use SimpleSAML\OpenID\Decorators\DateIntervalDecorator;
use SimpleSAML\OpenID\Decorators\HttpClientDecorator;
use SimpleSAML\OpenID\Exceptions\FetchException;
use SimpleSAML\OpenID\Exceptions\JwsException;
use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory;
use SimpleSAML\OpenID\Helpers;
use SimpleSAML\OpenID\Jws\JwsFetcher;
use SimpleSAML\OpenID\Utils\ArtifactFetcher;
use Throwable;

class EntityStatementFetcher
class EntityStatementFetcher extends JwsFetcher
{
public function __construct(
protected readonly ArtifactFetcher $artifactFetcher,
protected readonly EntityStatementFactory $entityStatementFactory,
protected readonly DateIntervalDecorator $maxCacheDuration,
protected readonly Helpers $helpers,
protected readonly ?LoggerInterface $logger = null,
private readonly EntityStatementFactory $parsedJwsFactory,
ArtifactFetcher $artifactFetcher,
DateIntervalDecorator $maxCacheDuration,
Helpers $helpers,
?LoggerInterface $logger = null,
) {
parent::__construct($parsedJwsFactory, $artifactFetcher, $maxCacheDuration, $helpers, $logger);
}

protected function buildJwsInstance(string $token): EntityStatement
{
return $this->parsedJwsFactory->fromToken($token);
}

protected function getExpectedContentTypeHttpHeader(): string
{
return ContentTypesEnum::ApplicationEntityStatementJwt->value;
}

/**
Expand Down Expand Up @@ -104,27 +111,27 @@ public function fromCacheOrNetwork(string $uri): EntityStatement
* @param string $uri
* @return \SimpleSAML\OpenID\Federation\EntityStatement|null
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
* @throws \SimpleSAML\OpenID\Exceptions\FetchException
*/
public function fromCache(string $uri): ?EntityStatement
{
$this->logger?->debug(
'Trying to get entity statement token from cache.',
compact('uri'),
);
$entityStatement = parent::fromCache($uri);

$jws = $this->artifactFetcher->fromCacheAsString($uri);

if (!is_string($jws)) {
$this->logger?->debug('Entity statement token not found in cache.', compact('uri'));
if (is_null($entityStatement)) {
return null;
}

$this->logger?->debug(
'Entity statement token found in cache, trying to build instance.',
compact('uri'),
if (is_a($entityStatement, EntityStatement::class)) {
return $entityStatement;
}

$message = 'Unexpected entity statement instance encountered for cache fetch.';
$this->logger?->error(
$message,
compact('uri', 'entityStatement'),
);

return $this->prepareEntityStatement($jws);
throw new FetchException($message);
}

/**
Expand All @@ -135,57 +142,18 @@ public function fromCache(string $uri): ?EntityStatement
*/
public function fromNetwork(string $uri): EntityStatement
{
$response = $this->artifactFetcher->fromNetwork($uri);

if ($response->getStatusCode() !== 200) {
$message = sprintf(
'Unexpected HTTP response for entity statement fetch, status code: %s, reason: %s. URI %s',
$response->getStatusCode(),
$response->getReasonPhrase(),
$uri,
);
$this->logger?->error($message);
throw new FetchException($message);
}
$entityStatement = parent::fromNetwork($uri);

/** @psalm-suppress InvalidLiteralArgument */
if (
!str_contains(
$response->getHeaderLine(HttpHeadersEnum::ContentType->value),
ContentTypesEnum::ApplicationEntityStatementJwt->value,
)
) {
$message = sprintf(
'Unexpected content type in response for entity statement fetch: %s, expected: %s. URI %s',
$response->getHeaderLine(HttpHeadersEnum::ContentType->value),
ContentTypesEnum::ApplicationEntityStatementJwt->value,
$uri,
);
$this->logger?->error($message);
throw new FetchException($message);
if (is_a($entityStatement, EntityStatement::class)) {
return $entityStatement;
}

$token = $response->getBody()->getContents();
$this->logger?->debug('Successful HTTP response for entity statement fetch.', compact('uri', 'token'));
$this->logger?->debug('Proceeding to EntityStatement instance building.');

$entityStatement = $this->entityStatementFactory->fromToken($token);
$this->logger?->debug('Entity Statement instance built, saving its token to cache.', compact('uri', 'token'));

$cacheTtl = $this->maxCacheDuration->lowestInSecondsComparedToExpirationTime(
$entityStatement->getExpirationTime(),
$message = 'Unexpected entity statement instance encountered for network fetch.';
$this->logger?->error(
$message,
compact('uri', 'entityStatement'),
);
$this->artifactFetcher->cacheIt($token, $cacheTtl, $uri);

$this->logger?->debug('Returning built Entity Statement instance.', compact('uri', 'token'));
return $entityStatement;
}

/**
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
*/
protected function prepareEntityStatement(string $jws): EntityStatement
{
return $this->entityStatementFactory->fromToken($jws);
throw new FetchException($message);
}
}
28 changes: 28 additions & 0 deletions src/Jws/AbstractJwsFetcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\OpenID\Jws;

use Psr\Log\LoggerInterface;
use SimpleSAML\OpenID\Decorators\DateIntervalDecorator;
use SimpleSAML\OpenID\Helpers;
use SimpleSAML\OpenID\Jws\Factories\ParsedJwsFactory;
use SimpleSAML\OpenID\Utils\ArtifactFetcher;

abstract class AbstractJwsFetcher
{
public function __construct(
protected readonly ArtifactFetcher $artifactFetcher,
protected readonly DateIntervalDecorator $maxCacheDuration,
protected readonly Helpers $helpers,
protected readonly ?LoggerInterface $logger = null,
) {
}

/**
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
*/
abstract protected function buildJwsInstance(string $token): ParsedJws;
abstract protected function getExpectedContentTypeHttpHeader(): ?string;
}
139 changes: 139 additions & 0 deletions src/Jws/JwsFetcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\OpenID\Jws;

use Psr\Log\LoggerInterface;
use SimpleSAML\OpenID\Codebooks\HttpHeadersEnum;
use SimpleSAML\OpenID\Decorators\DateIntervalDecorator;
use SimpleSAML\OpenID\Exceptions\FetchException;
use SimpleSAML\OpenID\Helpers;
use SimpleSAML\OpenID\Jws\Factories\ParsedJwsFactory;
use SimpleSAML\OpenID\Utils\ArtifactFetcher;

class JwsFetcher extends AbstractJwsFetcher
{
public function __construct(
private readonly ParsedJwsFactory $parsedJwsFactory,
ArtifactFetcher $artifactFetcher,
DateIntervalDecorator $maxCacheDuration,
Helpers $helpers,
?LoggerInterface $logger = null,
) {
parent::__construct($artifactFetcher, $maxCacheDuration, $helpers, $logger);
}

/**
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
*/
protected function buildJwsInstance(string $token): ParsedJws
{
return $this->parsedJwsFactory->fromToken($token);
}

protected function getExpectedContentTypeHttpHeader(): ?string
{
return null;
}

/**
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
* @throws \SimpleSAML\OpenID\Exceptions\FetchException
*/
public function fromCacheOrNetwork(string $uri): ParsedJws
{
return $this->fromCache($uri) ?? $this->fromNetwork($uri);
}

/**
* Fetch JWS from cache, if available. URI is used as cache key.
*
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
*/
public function fromCache(string $uri): ?ParsedJws
{
$this->logger?->debug(
'Trying to get JWS token from cache.',
compact('uri'),
);

$jws = $this->artifactFetcher->fromCacheAsString($uri);

if (!is_string($jws)) {
$this->logger?->debug('JWS token not found in cache.', compact('uri'));
return null;
}

$this->logger?->debug(
'JWS token found in cache, trying to build instance.',
compact('uri'),
);

return $this->buildJwsInstance($jws);
}

/**
* Fetch JWS from network. Each successful fetch will be cached, with URI being used as a cache key.
*
* @throws \SimpleSAML\OpenID\Exceptions\FetchException
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
*/
public function fromNetwork(string $uri): ParsedJws
{
$this->logger?->debug(
'Trying to fetch JWS token from network.',
compact('uri'),
);

$response = $this->artifactFetcher->fromNetwork($uri);

if ($response->getStatusCode() !== 200) {
$message = sprintf(
'Unexpected HTTP response for JWS fetch, status code: %s, reason: %s. URI %s',
$response->getStatusCode(),
$response->getReasonPhrase(),
$uri,
);
$this->logger?->error($message);
throw new FetchException($message);
}

/** @psalm-suppress InvalidLiteralArgument */
if (
is_string($expectedContentTypeHttpHeader = $this->getExpectedContentTypeHttpHeader()) &&
(!str_contains(
$response->getHeaderLine(HttpHeadersEnum::ContentType->value),
$expectedContentTypeHttpHeader,
))
) {
$message = sprintf(
'Unexpected content type in response for JWS fetch: %s, expected: %s. URI %s',
$response->getHeaderLine(HttpHeadersEnum::ContentType->value),
$expectedContentTypeHttpHeader,
$uri,
);
$this->logger?->error($message);
throw new FetchException($message);
}

$token = $response->getBody()->getContents();
$this->logger?->debug('Successful HTTP response for JWS fetch.', compact('uri', 'token'));
$this->logger?->debug('Proceeding to JWS instance building.');

$jwsInstance = $this->buildJwsInstance($token);
$this->logger?->debug('JWS instance built, saving its token to cache.', compact('uri', 'token'));

$cacheTtl = is_int($expirationTime = $jwsInstance->getExpirationTime()) ?
$this->maxCacheDuration->lowestInSecondsComparedToExpirationTime(
$expirationTime,
) :
$this->maxCacheDuration->getInSeconds();

$this->artifactFetcher->cacheIt($token, $cacheTtl, $uri);

$this->logger?->debug('Returning built JWS instance.', compact('uri', 'token'));

return $jwsInstance;
}
}
15 changes: 10 additions & 5 deletions tests/src/Federation/EntityStatementFetcherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace SimpleSAML\Test\OpenID\Federation;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
Expand All @@ -13,42 +14,46 @@
use SimpleSAML\OpenID\Federation\EntityStatementFetcher;
use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory;
use SimpleSAML\OpenID\Helpers;
use SimpleSAML\OpenID\Jws\AbstractJwsFetcher;
use SimpleSAML\OpenID\Jws\JwsFetcher;
use SimpleSAML\OpenID\Utils\ArtifactFetcher;

#[CoversClass(EntityStatementFetcher::class)]
#[UsesClass(AbstractJwsFetcher::class)]
#[UsesClass(JwsFetcher::class)]
class EntityStatementFetcherTest extends TestCase
{
protected MockObject $artifactFetcherMock;
protected MockObject $entityStatementFactoryMock;
protected MockObject $artifactFetcherMock;
protected MockObject $maxCacheDurationMock;
protected MockObject $helpersMock;
protected MockObject $loggerMock;

protected function setUp(): void
{
$this->artifactFetcherMock = $this->createMock(ArtifactFetcher::class);
$this->entityStatementFactoryMock = $this->createMock(EntityStatementFactory::class);
$this->artifactFetcherMock = $this->createMock(ArtifactFetcher::class);
$this->maxCacheDurationMock = $this->createMock(DateIntervalDecorator::class);
$this->helpersMock = $this->createMock(Helpers::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);
}

protected function sut(
?ArtifactFetcher $artifactFetcher = null,
?EntityStatementFactory $entityStatementFactory = null,
?ArtifactFetcher $artifactFetcher = null,
?DateIntervalDecorator $maxCacheDuration = null,
?Helpers $helpers = null,
?LoggerInterface $logger = null,
): EntityStatementFetcher {
$artifactFetcher ??= $this->artifactFetcherMock;
$entityStatementFactory ??= $this->entityStatementFactoryMock;
$artifactFetcher ??= $this->artifactFetcherMock;
$maxCacheDuration ??= $this->maxCacheDurationMock;
$helpers ??= $this->helpersMock;
$logger ??= $this->loggerMock;

return new EntityStatementFetcher(
$artifactFetcher,
$entityStatementFactory,
$artifactFetcher,
$maxCacheDuration,
$helpers,
$logger,
Expand Down
Loading

0 comments on commit ff2ffe6

Please sign in to comment.