Skip to content

Commit 8ae7d1e

Browse files
authored
Enable federation list endpoint (#301)
* Enable federation list endpoint
1 parent bc836b5 commit 8ae7d1e

File tree

11 files changed

+305
-14
lines changed

11 files changed

+305
-14
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Currently, the following OIDF features are supported:
2828
* federation participation limiting based on Trust Marks
2929
* endpoint for issuing configuration entity statement (statement about itself)
3030
* fetch endpoint for issuing statements about subordinates (registered clients)
31+
* subordinate listing endpoint
3132

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

UPGRADE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ be fetched by RPs, and do the switch between "old" and "new" key pair when you f
1515
- Federation participation limiting based on Trust Marks
1616
- Endpoint for issuing configuration entity statement (statement about itself)
1717
- Fetch endpoint for issuing statements about subordinates (registered clients)
18+
- (from v6.1) Subordinate listing endpoint
1819
- Clients can now be configured with new properties:
1920
- Entity Identifier
2021
- Supported OpenID Federation Registration Types

routing/routes/routes.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use SimpleSAML\Module\oidc\Controllers\ConfigurationDiscoveryController;
1616
use SimpleSAML\Module\oidc\Controllers\EndSessionController;
1717
use SimpleSAML\Module\oidc\Controllers\Federation\EntityStatementController;
18+
use SimpleSAML\Module\oidc\Controllers\Federation\SubordinateListingsController;
1819
use SimpleSAML\Module\oidc\Controllers\JwksController;
1920
use SimpleSAML\Module\oidc\Controllers\UserInfoController;
2021
use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum;
@@ -96,4 +97,8 @@
9697
$routes->add(RoutesEnum::FederationFetch->name, RoutesEnum::FederationFetch->value)
9798
->controller([EntityStatementController::class, 'fetch'])
9899
->methods([HttpMethodsEnum::GET->value]);
100+
101+
$routes->add(RoutesEnum::FederationList->name, RoutesEnum::FederationList->value)
102+
->controller([SubordinateListingsController::class, 'list'])
103+
->methods([HttpMethodsEnum::GET->value]);
99104
};

src/Codebooks/RoutesEnum.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,5 @@ enum RoutesEnum: string
4646

4747
case FederationConfiguration = '.well-known/openid-federation';
4848
case FederationFetch = 'federation/fetch';
49+
case FederationList = 'federation/list';
4950
}

src/Controllers/Federation/EntityStatementController.php

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
namespace SimpleSAML\Module\oidc\Controllers\Federation;
66

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

212210
public function fetch(Request $request): Response
213211
{
214-
$subject = $request->query->get(ClaimsEnum::Sub->value);
212+
$subject = $request->query->getString(ClaimsEnum::Sub->value);
215213

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

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

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

237-
$client = $this->clientRepository->findByEntityIdentifier($subject);
234+
$client = $this->clientRepository->findFederatedByEntityIdentifier($subject);
238235
if (empty($client)) {
239236
return $this->routes->newJsonErrorResponse(
240237
ErrorsEnum::NotFound->value,
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Module\oidc\Controllers\Federation;
6+
7+
use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface;
8+
use SimpleSAML\Module\oidc\ModuleConfig;
9+
use SimpleSAML\Module\oidc\Repositories\ClientRepository;
10+
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
11+
use SimpleSAML\Module\oidc\Utils\Routes;
12+
use SimpleSAML\OpenID\Codebooks\ErrorsEnum;
13+
use SimpleSAML\OpenID\Codebooks\ParamsEnum;
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\Response;
16+
17+
class SubordinateListingsController
18+
{
19+
/**
20+
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
21+
*/
22+
public function __construct(
23+
protected readonly ModuleConfig $moduleConfig,
24+
protected readonly ClientRepository $clientRepository,
25+
protected readonly Routes $routes,
26+
) {
27+
if (!$this->moduleConfig->getFederationEnabled()) {
28+
throw OidcServerException::forbidden('federation capabilities not enabled');
29+
}
30+
}
31+
32+
public function list(Request $request): Response
33+
{
34+
// If unsupported query parameter is provided, we have to respond with an error: "If the responder does not
35+
// support this feature, it MUST use the HTTP status code 400 and the content type application/json, with
36+
// the error code unsupported_parameter."
37+
38+
// Currently, we don't support any of the mentioned params in the spec, so let's return error for any of them.
39+
$unsupportedParams = [
40+
ParamsEnum::EntityType->value,
41+
ParamsEnum::TrustMarked->value,
42+
ParamsEnum::TrustMarkId->value,
43+
ParamsEnum::Intermediate->value,
44+
];
45+
46+
$requestedParams = array_keys($request->query->all());
47+
48+
if (!empty($intersectedParams = array_intersect($unsupportedParams, $requestedParams))) {
49+
return $this->routes->newJsonErrorResponse(
50+
ErrorsEnum::UnsupportedParameter->value,
51+
'Unsupported parameter: ' . implode(', ', $intersectedParams),
52+
400,
53+
);
54+
}
55+
56+
$subordinateEntityIdList = array_filter(array_map(
57+
function (ClientEntityInterface $clientEntity): ?string {
58+
return $clientEntity->getEntityIdentifier();
59+
},
60+
$this->clientRepository->findAllFederated(),
61+
));
62+
63+
return $this->routes->newJsonResponse(
64+
$subordinateEntityIdList,
65+
);
66+
}
67+
}

src/Repositories/ClientRepository.php

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,10 @@ public function findByEntityIdentifier(string $entityIdentifier, ?string $owner
153153
<<<EOS
154154
SELECT * FROM {$this->getTableName()}
155155
WHERE
156-
entity_identifier = :entity_identifier AND
157-
is_enabled = :is_enabled AND
158-
is_federated = :is_federated
156+
entity_identifier = :entity_identifier
159157
EOS,
160158
[
161159
'entity_identifier' => $entityIdentifier,
162-
'is_enabled' => [true, PDO::PARAM_BOOL],
163-
'is_federated' => [true, PDO::PARAM_BOOL],
164160
],
165161
$owner,
166162
);
@@ -190,6 +186,29 @@ public function findByEntityIdentifier(string $entityIdentifier, ?string $owner
190186
return $clientEntity;
191187
}
192188

189+
public function findFederatedByEntityIdentifier(
190+
string $entityIdentifier,
191+
?string $owner = null,
192+
): ?ClientEntityInterface {
193+
$clientEntity = $this->findByEntityIdentifier($entityIdentifier, $owner);
194+
195+
if (is_null($clientEntity)) {
196+
return null;
197+
}
198+
199+
if (
200+
is_null($clientEntity->getEntityIdentifier()) ||
201+
(! $clientEntity->isEnabled()) ||
202+
(! $clientEntity->isFederated()) ||
203+
(!is_array($clientEntity->getFederationJwks())) ||
204+
$clientEntity->isExpired()
205+
) {
206+
return null;
207+
}
208+
209+
return $clientEntity;
210+
}
211+
193212
private function addOwnerWhereClause(string $query, array $params, ?string $owner = null): array
194213
{
195214
if (isset($owner)) {
@@ -234,6 +253,47 @@ public function findAll(?string $owner = null): array
234253
return $clients;
235254
}
236255

256+
/**
257+
* @return \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface[]
258+
* @throws \JsonException
259+
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
260+
*/
261+
public function findAllFederated(?string $owner = null): array
262+
{
263+
/**
264+
* @var string $query
265+
* @var array $params
266+
*/
267+
[$query, $params] = $this->addOwnerWhereClause(
268+
<<<EOS
269+
SELECT * FROM {$this->getTableName()}
270+
WHERE
271+
entity_identifier IS NOT NULL AND
272+
federation_jwks IS NOT NULL AND
273+
is_enabled = :is_enabled AND
274+
is_federated = :is_federated
275+
EOS,
276+
[
277+
'is_enabled' => [true, PDO::PARAM_BOOL],
278+
'is_federated' => [true, PDO::PARAM_BOOL],
279+
],
280+
$owner,
281+
);
282+
$stmt = $this->database->read(
283+
"$query ORDER BY name ASC",
284+
$params,
285+
);
286+
287+
$clients = [];
288+
289+
/** @var array $state */
290+
foreach ($stmt->fetchAll() as $state) {
291+
$clients[] = $this->clientEntityFactory->fromState($state);
292+
}
293+
294+
return $clients;
295+
}
296+
237297
/**
238298
* @return array{
239299
* numPages: int,

src/Utils/Routes.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,9 @@ public function urlFederationFetch(array $parameters = []): string
193193
{
194194
return $this->getModuleUrl(RoutesEnum::FederationFetch->value, $parameters);
195195
}
196+
197+
public function urlFederationList(array $parameters = []): string
198+
{
199+
return $this->getModuleUrl(RoutesEnum::FederationList->value, $parameters);
200+
}
196201
}

templates/clients/includes/form.twig

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@
143143

144144
<br>
145145
<h4>{{ 'OpenID Federation Related Properties'|trans }}</h4>
146-
146+
<span class="pure-form-message">
147+
{% 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 %}
148+
</span>
147149
<label for="">{{ 'Is Federated'|trans }}</label>
148150
<label for="radio-option-federated-yes" class="pure-radio">
149151
<input type="radio"
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Test\Module\oidc\unit\Controllers\Federation;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\MockObject\MockObject;
9+
use PHPUnit\Framework\TestCase;
10+
use SimpleSAML\Module\oidc\Controllers\Federation\SubordinateListingsController;
11+
use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface;
12+
use SimpleSAML\Module\oidc\ModuleConfig;
13+
use SimpleSAML\Module\oidc\Repositories\ClientRepository;
14+
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
15+
use SimpleSAML\Module\oidc\Utils\Routes;
16+
use SimpleSAML\OpenID\Codebooks\ErrorsEnum;
17+
use SimpleSAML\OpenID\Codebooks\ParamsEnum;
18+
use Symfony\Component\HttpFoundation\ParameterBag;
19+
use Symfony\Component\HttpFoundation\Request;
20+
21+
#[CoversClass(SubordinateListingsController::class)]
22+
final class SubordinateListingsControllerTest extends TestCase
23+
{
24+
private MockObject $moduleConfigMock;
25+
private MockObject $clientRepositoryMock;
26+
private MockObject $routesMock;
27+
28+
private bool $isFederationEnabled;
29+
private MockObject $requestMock;
30+
private MockObject $requestQueryMock;
31+
32+
33+
protected function setUp(): void
34+
{
35+
$this->moduleConfigMock = $this->createMock(ModuleConfig::class);
36+
$this->clientRepositoryMock = $this->createMock(ClientRepository::class);
37+
$this->routesMock = $this->createMock(Routes::class);
38+
39+
$this->isFederationEnabled = true;
40+
41+
$this->requestMock = $this->createMock(Request::class);
42+
$this->requestQueryMock = $this->createMock(ParameterBag::class);
43+
$this->requestMock->query = $this->requestQueryMock;
44+
}
45+
46+
public function sut(
47+
?ModuleConfig $moduleConfig = null,
48+
?ClientRepository $clientRepository = null,
49+
?Routes $routes = null,
50+
?bool $federationEnabled = null,
51+
): SubordinateListingsController {
52+
$federationEnabled = $federationEnabled ?? $this->isFederationEnabled;
53+
$this->moduleConfigMock->method('getFederationEnabled')->willReturn($federationEnabled);
54+
55+
$moduleConfig = $moduleConfig ?? $this->moduleConfigMock;
56+
$clientRepository = $clientRepository ?? $this->clientRepositoryMock;
57+
$routes = $routes ?? $this->routesMock;
58+
59+
return new SubordinateListingsController(
60+
$moduleConfig,
61+
$clientRepository,
62+
$routes,
63+
);
64+
}
65+
66+
public function testCanConstruct(): void
67+
{
68+
$this->assertInstanceOf(SubordinateListingsController::class, $this->sut());
69+
}
70+
71+
public function testThrowsIfFederationNotEnabled(): void
72+
{
73+
$this->expectException(OidcServerException::class);
74+
$this->expectExceptionMessage('refused');
75+
76+
$this->sut(federationEnabled: false);
77+
}
78+
79+
public function testCanListFederatedEntities(): void
80+
{
81+
$client = $this->createMock(ClientEntityInterface::class);
82+
$client->method('getEntityIdentifier')->willReturn('entity-id');
83+
84+
$federatedEntities = [
85+
$client,
86+
];
87+
88+
$this->clientRepositoryMock->expects($this->once())->method('findAllFederated')
89+
->willReturn($federatedEntities);
90+
91+
$this->routesMock->expects($this->once())->method('newJsonResponse')
92+
->with([
93+
$client->getEntityIdentifier(),
94+
]);
95+
96+
$this->sut()->list($this->requestMock);
97+
}
98+
99+
public function testListReturnsErrorOnUnsuportedQueryParameter(): void
100+
{
101+
$this->requestQueryMock->method('all')->willReturn([
102+
ParamsEnum::EntityType->value => 'something',
103+
]);
104+
105+
$this->routesMock->expects($this->once())->method('newJsonErrorResponse')
106+
->with(ErrorsEnum::UnsupportedParameter->value);
107+
108+
$this->sut()->list($this->requestMock);
109+
}
110+
}

0 commit comments

Comments
 (0)