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
8 changes: 8 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Upgrading

## [3.1.0] - 2026-05-04

### Added

In src/FederatedClient.php added methods `discoverOpenIdProviders` and
`discoverEntities` to `FederatedClient` class, which can be used to discover
OPs and entities in federated environments.

## [3.0.0] - 2025-12-02

Major release with breaking changes to the client instantiation API.
Expand Down
72 changes: 71 additions & 1 deletion docs/3-Federated-Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ from the OpenID Provider (OP) to a configured Trust Anchor.
the first authentication request.
- **Metadata Management**: Handles the generation of the RP's Entity
Configuration and metadata, including keys and trust marks.
- **Federation Discovery**: Supports discovering OPs and their metadata.
- **OIDC Flow**: Manages the authorization code flow, including PKCE,
state/nonce validation, and ID Token verification.
- **Caching**: Efficiently caches resolved trust chains and metadata to improve
Expand Down Expand Up @@ -130,4 +131,73 @@ exit();
```

See the [Entity Configuration Endpoint Example](../examples/FederatedClient/FederationConfigurationController.php)
for a sample implementation.
for a sample implementation.

## Federation Discovery (from v3.1)

The client can discover OPs and their metadata using the `FederationDiscovery`
service.

```php
/** @var \Cicnavi\Oidc\FederatedClient $client */

// Optionally define claim paths to sort discovered OPs by their display names
// (e.g., for user-friendly display in a login UI). The paths are relative to
// the OP's metadata structure. The method also has default paths it checks
// if not provided.
$sortClaimPaths = [
['metadata', 'openid_provider', 'display_name'],
['metadata', 'federation_entity', 'display_name'],
];
$forceRefresh = false; // Set to true to bypass cache and fetch fresh data

$openIdProvidersPerTrustAnchor = $client->discoverOpenIdProviders($sortClaimPaths, $forceRefresh);

// The result $openIdProvidersPerTrustAnchor is an associative array where each
// trust anchor ID maps to its list of discovered entities:
// [trustAnchorId => [entityId1 => entityPayload1, entityId2 => entityPayload2, ...]]
// Use it to display available OPs for users to choose from during login.

```

This operation can be time-consuming on the first run because it may require a
full traversal of the federation under each configured Trust Anchor. Results
are cached and subsequent calls are typically much faster.

To warm up discovery caches (for example, from a CLI command or scheduled job),
you can trigger discovery in advance:

```php
/** @var \Cicnavi\Oidc\FederatedClient $client */

// Warm up OP discovery caches for all configured trust anchors.
// Keep forceRefresh=true only for explicit refresh jobs.
$client->discoverOpenIdProviders(forceRefresh: true);
```

### Advanced Discovery

If you need to discover entities other than OpenID Providers, use
`discoverEntities()` and provide criteria explicitly:

```php
/** @var \Cicnavi\Oidc\FederatedClient $client */

$entitiesPerTrustAnchor = $client->discoverEntities(
criteria: [
'entity_type' => ['openid_provider'],
// Optional filters:
// 'trust_mark_type' => ['https://example.org/trust-mark/type'],
// 'query' => 'search text',
],
sortClaimPaths: [
['metadata', 'openid_provider', 'display_name'],
['metadata', 'federation_entity', 'display_name'],
],
sortOrder: 'asc',
forceRefresh: false,
);
```

`discoverEntities()` returns the same grouped shape:
`[trustAnchorId => [entityId => entityPayload, ...]]`.
15 changes: 11 additions & 4 deletions examples/FederatedClient/FederatedClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public function __construct(

public function build(): FederatedClient
{
// For federation discovery, we can inject our own
// EntityCollectionStoreInterface implementation, but in this example,
// we'll just use the default implementation.
$entityCollectionStore = null;

return new FederatedClient(
entityConfig: $this->config['entity_config'],
relyingPartyConfig: $this->config['relying_party_config'],
Expand All @@ -40,11 +45,11 @@ public function build(): FederatedClient
maxCacheDuration: $this->config['max_cache_duration'],
timestampValidationLeeway: $this->config['timestamp_validation_leeway'],
supportedAlgorithms: $this->config['supported_algorithms'] ?? new SupportedAlgorithms(
new SignatureAlgorithmBag(
SignatureAlgorithmEnum::ES256,
SignatureAlgorithmEnum::RS256,
new SignatureAlgorithmBag(
SignatureAlgorithmEnum::ES256,
SignatureAlgorithmEnum::RS256,
),
),
),
logger: $this->logger,
maxTrustChainDepth: $this->config['max_trust_chain_depth'] ?? 9,
defaultTrustMarkStatusEndpointUsagePolicyEnum: $this->config['default_trust_mark_status_endpoint_usage_policy'] ?? TrustMarkStatusEndpointUsagePolicyEnum::NotUsed,
Expand All @@ -55,6 +60,8 @@ public function build(): FederatedClient
fetchUserinfoClaims: $this->config['fetch_userinfo_claims'] ?? true,
pkceCodeChallengeMethod: $this->config['pkce_code_challenge_method'] ?? PkceCodeChallengeMethodEnum::S256,
defaultAuthorizationRequestMethod: $this->config['authorization_request_method'] ?? AuthorizationRequestMethodEnum::FormPost,
maxDiscoveryDepth: $this->config['max_discovery_depth'] ?? 10,
entityCollectionStore: $entityCollectionStore,
);
}
}
135 changes: 135 additions & 0 deletions examples/FederatedClient/FederationDiscoveryController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);


namespace FederatedClient;

use Cicnavi\Oidc\FederatedClient;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;

class FederationDiscoveryController
{
public function __construct(
protected readonly FederatedClient $federatedClient,
protected readonly LoggerInterface $logger,
)
{
}

/**
* Discover OpenID Providers grouped by trust anchor.
*
* Query params:
* - force_refresh=1|true (optional)
*/
public function openIdProviders(ServerRequestInterface $request): array
{
$queryParams = $request->getQueryParams();
$forceRefresh = $this->parseBool($queryParams['force_refresh'] ?? null);

$providersPerTrustAnchor = $this->federatedClient->discoverOpenIdProviders(
sortClaimPaths: [
['metadata', 'openid_provider', 'display_name'],
['metadata', 'federation_entity', 'display_name'],
],
forceRefresh: $forceRefresh,
);

$this->logger->info('OpenID Provider discovery completed.', [
'force_refresh' => $forceRefresh,
'trust_anchors' => count($providersPerTrustAnchor),
]);

return $providersPerTrustAnchor;
}

/**
* Discover entities grouped by trust anchor using custom criteria.
*
* Query params:
* - entity_type=openid_provider,federation_entity (optional)
* - trust_mark_type=https://example.org/tm/1,https://example.org/tm/2 (optional)
* - query=search text (optional)
* - sort_order=asc|desc (optional, default: asc)
* - force_refresh=1|true (optional)
*/
public function entities(ServerRequestInterface $request): array
{
$queryParams = $request->getQueryParams();

$criteria = array_filter([
'entity_type' => $this->parseCsv($queryParams['entity_type'] ?? null),
'trust_mark_type' => $this->parseCsv($queryParams['trust_mark_type'] ?? null),
'query' => $this->parseString($queryParams['query'] ?? null),
], static fn (mixed $value): bool => $value !== null && $value !== []);

$sortOrder = $this->parseSortOrder($queryParams['sort_order'] ?? null);
$forceRefresh = $this->parseBool($queryParams['force_refresh'] ?? null);

$entitiesPerTrustAnchor = $this->federatedClient->discoverEntities(
criteria: $criteria,
sortClaimPaths: [
['metadata', 'openid_provider', 'display_name'],
['metadata', 'federation_entity', 'display_name'],
],
sortOrder: $sortOrder,
forceRefresh: $forceRefresh,
);

$this->logger->info('Federation entity discovery completed.', [
'criteria' => $criteria,
'sort_order' => $sortOrder,
'force_refresh' => $forceRefresh,
'trust_anchors' => count($entitiesPerTrustAnchor),
]);

return $entitiesPerTrustAnchor;
}

private function parseSortOrder(mixed $sortOrder): string
{
if (!is_string($sortOrder)) {
return 'asc';
}

return strtolower($sortOrder) === 'desc' ? 'desc' : 'asc';
}

private function parseCsv(mixed $value): ?array
{
if (!is_string($value) || trim($value) === '') {
return null;
}

$values = array_values(array_filter(array_map(
static fn (string $item): string => trim($item),
explode(',', $value)
)));

return $values === [] ? null : $values;
}

private function parseString(mixed $value): ?string
{
if (!is_string($value) || trim($value) === '') {
return null;
}

return trim($value);
}

private function parseBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}

if (!is_string($value)) {
return false;
}

return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
}
8 changes: 3 additions & 5 deletions examples/FederatedClient/federated-client-config-example.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,7 @@
'fetch_userinfo_claims' => true,
'authorization_request_method' => AuthorizationRequestMethodEnum::FormPost,

// Hardcoded OpenID Providers, until discovery is implemented.
'open_id_providers' => new \SimpleSAML\OpenID\ValueAbstracts\UniqueStringBag(
'https://idp.mivanci.incubator.hexaa.eu',
'https://oidfed-op-demo.incubator.geant.org',
),
// Federation Discovery configuration.
'max_discovery_depth' => 10,

];
80 changes: 80 additions & 0 deletions src/FederatedClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use SimpleSAML\OpenID\Exceptions\InvalidValueException;
use SimpleSAML\OpenID\Exceptions\OpenIdException;
use SimpleSAML\OpenID\Exceptions\TrustChainException;
use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface;
use SimpleSAML\OpenID\Federation\TrustChain;
use SimpleSAML\OpenID\Jwks;
use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag;
Expand Down Expand Up @@ -140,6 +141,8 @@ public function __construct(
?RequestDataHandler $requestDataHandler = null,
// phpcs:ignore
protected readonly AuthorizationRequestMethodEnum $defaultAuthorizationRequestMethod = AuthorizationRequestMethodEnum::FormPost,
int $maxDiscoveryDepth = 10,
?EntityCollectionStoreInterface $entityCollectionStore = null,
) {
$this->cache = $cache ?? new FileCache('ofacpc-' . md5($this->entityConfig->getEntityId()));
$this->signatureKeyPairFactory = $signatureKeyPairFactory ?? new SignatureKeyPairFactory($this->jwk);
Expand Down Expand Up @@ -184,6 +187,8 @@ public function __construct(
logger: $this->logger,
client: $this->httpClient,
defaultTrustMarkStatusEndpointUsagePolicyEnum: $this->defaultTrustMarkStatusEndpointUsagePolicyEnum,
maxDiscoveryDepth: $maxDiscoveryDepth,
entityCollectionStore: $entityCollectionStore,
);

$this->federationSignatureKeyPairBag = $this->signatureKeyPairBagFactory->fromConfig(
Expand Down Expand Up @@ -856,4 +861,79 @@ public function getUserData(?ServerRequestInterface $request = null): array
fetchUserinfoClaims: $this->fetchUserinfoClaims
);
}

/**
* Discover OpenID Providers across all configured trus anchors.
*
* @param non-empty-array<int, non-empty-string[]> $sortClaimPaths Optional
* claim paths used for sorting the entities (multiple allowed). Example:
* [['metadata', 'openid_provider', 'display_name'], ['metadata', 'federation_entity', 'display_name']]
* @param bool $forceRefresh Whether to force refreshing the cache.
*
* @return array<string, array<string,array<string, mixed>>> An associative
* array where each trust anchor ID maps to its list of discovered OPs.
*/
public function discoverOpenIdProviders(
array $sortClaimPaths = [
['metadata', 'openid_provider', 'display_name'],
['metadata', 'federation_entity', 'display_name'],
],
bool $forceRefresh = false,
): array {
return $this->discoverEntities(
criteria: ['entity_type' => ['openid_provider']],
sortClaimPaths: [
['metadata', 'openid_provider', 'display_name'],
['metadata', 'federation_entity', 'display_name'],
],
forceRefresh: $forceRefresh,
);
}

/**
* Discovers federated entities across all configured trust anchors based on
* the provided criteria and sorting options.
*
* @param array{
* entity_type?: string[],
* trust_mark_type?: string[],
* query?: string,
* } $criteria $criteria Optional criteria to filter the discovered
* entities. Example: ['entity_type' => ['openid_provider']]
* @param non-empty-array<int, non-empty-string[]> $sortClaimPaths Optional
* claim paths used for sorting the entities (multiple allowed). Example:
* [['metadata', 'federation_entity', 'display_name']]
* @param 'asc'|'desc' $sortOrder The sorting order, either 'asc' (ascending)
* or 'desc' (descending). Defaults to 'asc'.
* @param bool $forceRefresh Whether to force refreshing the cache.
*
* @return array<string, array<string,array<string, mixed>>> An associative
* array where each trust anchor ID maps to its list of discovered entities.
* [trustAnchorId => [entityId1 => entityPayload1, entityId2 => entityPayload2, ...]]
*/
public function discoverEntities(
array $criteria = [],
array $sortClaimPaths = [['metadata', 'federation_entity', 'display_name']],
string $sortOrder = 'asc',
bool $forceRefresh = false,
): array {
$entities = [];

// Do entity discovery for each trust anchor.
foreach ($this->getEntityConfig()->getTrustAnchorBag()->getAllEntityIds() as $trustAnchorId) {
$entities[$trustAnchorId] = $this->getFederation()
->federationDiscovery()
->discover(
trustAnchorId: $trustAnchorId,
forceRefresh: $forceRefresh,
)->filter(
criteria: $criteria,
)->sort(
claimPaths: $sortClaimPaths,
sortOrder: $sortOrder,
)->getEntities();
}

return $entities;
}
}
2 changes: 1 addition & 1 deletion src/Helpers/HttpHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public static function normalizeSessionCookieParams(array $cookieParams): array
(!is_string($cookieParams['samesite']))
) {
$cookieParams['samesite'] = $defaultSameSiteValue;
} elseif (! in_array($cookieParams['samesite'], $validSameSiteValues)) {
} elseif (! in_array($cookieParams['samesite'], $validSameSiteValues, true)) {
error_log('Invalid SameSite session cookie attribute value in php.ini. Reverting to default value.');
$cookieParams['samesite'] = $defaultSameSiteValue;
} elseif (strcasecmp($cookieParams['samesite'], 'None') === 0) {
Expand Down
Loading
Loading