Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Currently, the following OIDF features are supported:
* federation participation limiting based on Trust Marks
* endpoint for issuing configuration entity statement (statement about itself)
* fetch endpoint for issuing statements about subordinates (registered clients)
* subordinate listing endpoint

OIDF support is implemented using the underlying [SimpleSAMLphp OpenID library](https://github.com/simplesamlphp/openid).

Expand Down
1 change: 1 addition & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ be fetched by RPs, and do the switch between "old" and "new" key pair when you f
- Federation participation limiting based on Trust Marks
- Endpoint for issuing configuration entity statement (statement about itself)
- Fetch endpoint for issuing statements about subordinates (registered clients)
- (from v6.1) Subordinate listing endpoint
- Clients can now be configured with new properties:
- Entity Identifier
- Supported OpenID Federation Registration Types
Expand Down
5 changes: 5 additions & 0 deletions routing/routes/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController;
use SimpleSAML\Module\oidc\Controllers\EndSessionController;
use SimpleSAML\Module\oidc\Controllers\Federation\EntityStatementController;
use SimpleSAML\Module\oidc\Controllers\Federation\SubordinateListingsController;
use SimpleSAML\Module\oidc\Controllers\JwksController;
use SimpleSAML\Module\oidc\Controllers\UserInfoController;
use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum;
Expand Down Expand Up @@ -96,4 +97,8 @@
$routes->add(RoutesEnum::FederationFetch->name, RoutesEnum::FederationFetch->value)
->controller([EntityStatementController::class, 'fetch'])
->methods([HttpMethodsEnum::GET->value]);

$routes->add(RoutesEnum::FederationList->name, RoutesEnum::FederationList->value)
->controller([SubordinateListingsController::class, 'list'])
->methods([HttpMethodsEnum::GET->value]);
};
1 change: 1 addition & 0 deletions src/Codebooks/RoutesEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ enum RoutesEnum: string

case FederationConfiguration = '.well-known/openid-federation';
case FederationFetch = 'federation/fetch';
case FederationList = 'federation/list';
}
11 changes: 4 additions & 7 deletions src/Controllers/Federation/EntityStatementController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace SimpleSAML\Module\oidc\Controllers\Federation;

use SimpleSAML\Module\oidc\Codebooks\RoutesEnum;
use SimpleSAML\Module\oidc\Helpers;
use SimpleSAML\Module\oidc\ModuleConfig;
use SimpleSAML\Module\oidc\Repositories\ClientRepository;
Expand Down Expand Up @@ -95,11 +94,10 @@ public function configuration(): Response
ClaimsEnum::HomepageUri->value => $this->moduleConfig->getHomepageUri(),
],
)),
ClaimsEnum::FederationFetchEndpoint->value =>
$this->moduleConfig->getModuleUrl(RoutesEnum::FederationFetch->value),
ClaimsEnum::FederationFetchEndpoint->value => $this->routes->urlFederationFetch(),
ClaimsEnum::FederationListEndpoint->value => $this->routes->urlFederationList(),
// TODO v7 mivanci Add when ready. Use ClaimsEnum for keys.
// https://openid.net/specs/openid-federation-1_0.html#name-federation-entity
//'federation_list_endpoint',
//'federation_resolve_endpoint',
//'federation_trust_mark_status_endpoint',
//'federation_trust_mark_list_endpoint',
Expand Down Expand Up @@ -211,7 +209,7 @@ public function configuration(): Response

public function fetch(Request $request): Response
{
$subject = $request->query->get(ClaimsEnum::Sub->value);
$subject = $request->query->getString(ClaimsEnum::Sub->value);

if (empty($subject)) {
return $this->routes->newJsonErrorResponse(
Expand All @@ -222,7 +220,6 @@ public function fetch(Request $request): Response
}

/** @var non-empty-string $subject */
$subject = (string)$subject;

$cachedSubordinateStatement = $this->federationCache?->get(
null,
Expand All @@ -234,7 +231,7 @@ public function fetch(Request $request): Response
return $this->prepareEntityStatementResponse((string)$cachedSubordinateStatement);
}

$client = $this->clientRepository->findByEntityIdentifier($subject);
$client = $this->clientRepository->findFederatedByEntityIdentifier($subject);
if (empty($client)) {
return $this->routes->newJsonErrorResponse(
ErrorsEnum::NotFound->value,
Expand Down
67 changes: 67 additions & 0 deletions src/Controllers/Federation/SubordinateListingsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\Module\oidc\Controllers\Federation;

use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface;
use SimpleSAML\Module\oidc\ModuleConfig;
use SimpleSAML\Module\oidc\Repositories\ClientRepository;
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
use SimpleSAML\Module\oidc\Utils\Routes;
use SimpleSAML\OpenID\Codebooks\ErrorsEnum;
use SimpleSAML\OpenID\Codebooks\ParamsEnum;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class SubordinateListingsController
{
/**
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
*/
public function __construct(
protected readonly ModuleConfig $moduleConfig,
protected readonly ClientRepository $clientRepository,
protected readonly Routes $routes,
) {
if (!$this->moduleConfig->getFederationEnabled()) {
throw OidcServerException::forbidden('federation capabilities not enabled');
}
}

public function list(Request $request): Response
{
// If unsupported query parameter is provided, we have to respond with an error: "If the responder does not
// support this feature, it MUST use the HTTP status code 400 and the content type application/json, with
// the error code unsupported_parameter."

// Currently, we don't support any of the mentioned params in the spec, so let's return error for any of them.
$unsupportedParams = [
ParamsEnum::EntityType->value,
ParamsEnum::TrustMarked->value,
ParamsEnum::TrustMarkId->value,
ParamsEnum::Intermediate->value,
];

$requestedParams = array_keys($request->query->all());

if (!empty($intersectedParams = array_intersect($unsupportedParams, $requestedParams))) {
return $this->routes->newJsonErrorResponse(
ErrorsEnum::UnsupportedParameter->value,
'Unsupported parameter: ' . implode(', ', $intersectedParams),
400,
);
}

$subordinateEntityIdList = array_filter(array_map(
function (ClientEntityInterface $clientEntity): ?string {
return $clientEntity->getEntityIdentifier();
},
$this->clientRepository->findAllFederated(),
));

return $this->routes->newJsonResponse(
$subordinateEntityIdList,
);
}
}
70 changes: 65 additions & 5 deletions src/Repositories/ClientRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,10 @@ public function findByEntityIdentifier(string $entityIdentifier, ?string $owner
<<<EOS
SELECT * FROM {$this->getTableName()}
WHERE
entity_identifier = :entity_identifier AND
is_enabled = :is_enabled AND
is_federated = :is_federated
entity_identifier = :entity_identifier
EOS,
[
'entity_identifier' => $entityIdentifier,
'is_enabled' => [true, PDO::PARAM_BOOL],
'is_federated' => [true, PDO::PARAM_BOOL],
],
$owner,
);
Expand Down Expand Up @@ -190,6 +186,29 @@ public function findByEntityIdentifier(string $entityIdentifier, ?string $owner
return $clientEntity;
}

public function findFederatedByEntityIdentifier(
string $entityIdentifier,
?string $owner = null,
): ?ClientEntityInterface {
$clientEntity = $this->findByEntityIdentifier($entityIdentifier, $owner);

if (is_null($clientEntity)) {
return null;
}

if (
is_null($clientEntity->getEntityIdentifier()) ||
(! $clientEntity->isEnabled()) ||
(! $clientEntity->isFederated()) ||
(!is_array($clientEntity->getFederationJwks())) ||
$clientEntity->isExpired()
) {
return null;
}

return $clientEntity;
}

private function addOwnerWhereClause(string $query, array $params, ?string $owner = null): array
{
if (isset($owner)) {
Expand Down Expand Up @@ -234,6 +253,47 @@ public function findAll(?string $owner = null): array
return $clients;
}

/**
* @return \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface[]
* @throws \JsonException
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
*/
public function findAllFederated(?string $owner = null): array
{
/**
* @var string $query
* @var array $params
*/
[$query, $params] = $this->addOwnerWhereClause(
<<<EOS
SELECT * FROM {$this->getTableName()}
WHERE
entity_identifier IS NOT NULL AND
federation_jwks IS NOT NULL AND
is_enabled = :is_enabled AND
is_federated = :is_federated
EOS,
[
'is_enabled' => [true, PDO::PARAM_BOOL],
'is_federated' => [true, PDO::PARAM_BOOL],
],
$owner,
);
$stmt = $this->database->read(
"$query ORDER BY name ASC",
$params,
);

$clients = [];

/** @var array $state */
foreach ($stmt->fetchAll() as $state) {
$clients[] = $this->clientEntityFactory->fromState($state);
}

return $clients;
}

/**
* @return array{
* numPages: int,
Expand Down
5 changes: 5 additions & 0 deletions src/Utils/Routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,9 @@ public function urlFederationFetch(array $parameters = []): string
{
return $this->getModuleUrl(RoutesEnum::FederationFetch->value, $parameters);
}

public function urlFederationList(array $parameters = []): string
{
return $this->getModuleUrl(RoutesEnum::FederationList->value, $parameters);
}
}
4 changes: 3 additions & 1 deletion templates/clients/includes/form.twig
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@

<br>
<h4>{{ 'OpenID Federation Related Properties'|trans }}</h4>

<span class="pure-form-message">
{% trans %}In order for an entity to participate in federation contexts (for example, to be listed as subordinate to this OP), it must have an Entity Identifier and Federation JWKS set. {% endtrans %}
</span>
<label for="">{{ 'Is Federated'|trans }}</label>
<label for="radio-option-federated-yes" class="pure-radio">
<input type="radio"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\Test\Module\oidc\unit\Controllers\Federation;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use SimpleSAML\Module\oidc\Controllers\Federation\SubordinateListingsController;
use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface;
use SimpleSAML\Module\oidc\ModuleConfig;
use SimpleSAML\Module\oidc\Repositories\ClientRepository;
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
use SimpleSAML\Module\oidc\Utils\Routes;
use SimpleSAML\OpenID\Codebooks\ErrorsEnum;
use SimpleSAML\OpenID\Codebooks\ParamsEnum;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;

#[CoversClass(SubordinateListingsController::class)]
final class SubordinateListingsControllerTest extends TestCase
{
private MockObject $moduleConfigMock;
private MockObject $clientRepositoryMock;
private MockObject $routesMock;

private bool $isFederationEnabled;
private MockObject $requestMock;
private MockObject $requestQueryMock;


protected function setUp(): void
{
$this->moduleConfigMock = $this->createMock(ModuleConfig::class);
$this->clientRepositoryMock = $this->createMock(ClientRepository::class);
$this->routesMock = $this->createMock(Routes::class);

$this->isFederationEnabled = true;

$this->requestMock = $this->createMock(Request::class);
$this->requestQueryMock = $this->createMock(ParameterBag::class);
$this->requestMock->query = $this->requestQueryMock;
}

public function sut(
?ModuleConfig $moduleConfig = null,
?ClientRepository $clientRepository = null,
?Routes $routes = null,
?bool $federationEnabled = null,
): SubordinateListingsController {
$federationEnabled = $federationEnabled ?? $this->isFederationEnabled;
$this->moduleConfigMock->method('getFederationEnabled')->willReturn($federationEnabled);

$moduleConfig = $moduleConfig ?? $this->moduleConfigMock;
$clientRepository = $clientRepository ?? $this->clientRepositoryMock;
$routes = $routes ?? $this->routesMock;

return new SubordinateListingsController(
$moduleConfig,
$clientRepository,
$routes,
);
}

public function testCanConstruct(): void
{
$this->assertInstanceOf(SubordinateListingsController::class, $this->sut());
}

public function testThrowsIfFederationNotEnabled(): void
{
$this->expectException(OidcServerException::class);
$this->expectExceptionMessage('refused');

$this->sut(federationEnabled: false);
}

public function testCanListFederatedEntities(): void
{
$client = $this->createMock(ClientEntityInterface::class);
$client->method('getEntityIdentifier')->willReturn('entity-id');

$federatedEntities = [
$client,
];

$this->clientRepositoryMock->expects($this->once())->method('findAllFederated')
->willReturn($federatedEntities);

$this->routesMock->expects($this->once())->method('newJsonResponse')
->with([
$client->getEntityIdentifier(),
]);

$this->sut()->list($this->requestMock);
}

public function testListReturnsErrorOnUnsuportedQueryParameter(): void
{
$this->requestQueryMock->method('all')->willReturn([
ParamsEnum::EntityType->value => 'something',
]);

$this->routesMock->expects($this->once())->method('newJsonErrorResponse')
->with(ErrorsEnum::UnsupportedParameter->value);

$this->sut()->list($this->requestMock);
}
}
Loading