diff --git a/docs/1-openid.md b/docs/1-openid.md index b088151..e98acb6 100644 --- a/docs/1-openid.md +++ b/docs/1-openid.md @@ -2,4 +2,5 @@ 1. [Installation](2-installation.md) 2. [OpenID Federation Tools](3-federation.md) +2.1 [Federation Discovery and Entity Collection](3.1-federation-discovery.md) 3. [OpenID for Verifiable Credential Issuance (OpenID4VCI) Tools](4-vci.md) diff --git a/docs/3-federation.md b/docs/3-federation.md index cd7d825..51531db 100644 --- a/docs/3-federation.md +++ b/docs/3-federation.md @@ -1,4 +1,4 @@ -# OpenID Federation Tools (draft 47) +# OpenID Federation Tools To use it, create an instance of the class `\SimpleSAML\OpenID\Federation`. diff --git a/docs/3.1-federation-discovery.md b/docs/3.1-federation-discovery.md new file mode 100644 index 0000000..aaf1944 --- /dev/null +++ b/docs/3.1-federation-discovery.md @@ -0,0 +1,284 @@ +# Federation Discovery and Entity Collection + +This library provides a high-performance, specification-compliant toolkit for +discovering entities within an OpenID Federation and interacting with Entity +Collection Endpoints. + +The functionality is split into two main operational modes: + +1. **Federation Discovery** — A top-down, recursive traversal of a federation +hierarchy starting from a Trust Anchor. +2. **Entity Collection** — A specialized protocol for optimized bulk-fetching +of entities, featuring support for server-side filtering, sorting, and +cursor-based pagination. + +All components are integrated and accessible through the +`\SimpleSAML\OpenID\Federation` facade. + +--- + +## Setup and Configuration + +To enable federation discovery, initialize the `Federation` facade with a +cache and (optionally) a logger. + +```php + [!NOTE] +> The store caches the **JWT payload arrays** of discovered entities. Actual +> JWS signatures and original JWT strings are managed by the +> `EntityStatementFetcher` which handles its own caching and validation logic. + +--- + +## Federation Discovery (Top-Down) + +Federation Discovery performs a recursive traversal of the hierarchy. +It starts at the Trust Anchor and follows `federation_list_endpoint` links +to discover all subordinates. + +### Discovering Entities + +```php +/** @var \SimpleSAML\OpenID\Federation $federationTools */ + +$trustAnchorId = 'https://trust-anchor.example.org/'; + +try { + // Traverse the federation and return an EntityCollection object. + $collection = $federationTools->federationDiscovery()->discover($trustAnchorId); + + // Get the raw map of Entity ID => Payload + $entities = $collection->getEntities(); + + // Convenience: Get just the discovered entity IDs + $ids = $federationTools->federationDiscovery()->discoverEntityIds($trustAnchorId); +} catch (\Throwable $exception) { + $logger->error('Federation discovery failed: ' . $exception->getMessage()); +} +``` + +### Discovery Logic & Loop Protection + +1. **Trust Anchor Config**: Fetches and validates the TA's Entity Configuration. +2. **Subordinate Listing**: Fetches the `federation_list_endpoint`. +If filters are provided, they are passed as query parameters to this endpoint. +3. **Recursion**: For each discovered subordinate, it fetches its +configuration and repeats the process. +4. **Loop Protection**: The algorithm tracks visited IDs to prevent +infinite loops and is limited by `maxDiscoveryDepth`. +5. **Deduplication**: Entities appearing in multiple branches are only stored +once. + +### Applying Filters During Discovery + +You can pass filters (like `entity_type`) directly to the discovery process. +These are passed to the remote `federation_list_endpoint` to optimize the +traversal: + +```php +$collection = $federationTools->federationDiscovery() + ->discover($trustAnchorId, filters: ['entity_type' => 'openid_provider']); +``` + +### Performance: Scheduled Refresh + +Discovery is an expensive network-heavy operation. You should run it in a +background process (Cron) using `forceRefresh: true` to populate the cache: + +```php +// In a background job: +$federationTools->federationDiscovery() + ->discover($trustAnchorId, forceRefresh: true); + +// In your web application (uses cache): +$collection = $federationTools->federationDiscovery()->discover($trustAnchorId); +``` + +--- + +## Entity Collection Client + +The Entity Collection Client allows fetching pre-filtered lists of entities +from a remote `federation_collection_endpoint`. This is much more efficient +than full traversal if the remote side supports it. + +### Bulk Fetching with Filters + +The client supports all standard OpenID Federation query parameters: + +```php +$endpoint = 'https://federation.example.org/collection'; + +$collection = $federationTools->federationDiscovery()->fetchFromCollectionEndpoint( + $endpoint, + [ + 'entity_type' => ['openid_provider'], + 'trust_mark_type' => ['https://example.org/marks/certified'], + 'trust_anchor' => 'https://trust-anchor.example.org/', + 'query' => 'university', + 'limit' => 50, + 'entity_claims' => ['entity_types', 'ui_infos'], // Request specific claims + ] +); + +foreach ($collection->getEntities() as $id => $payload) { + // Process entity... +} +``` + +### Client-Side Caching + +`fetchFromCollectionEndpoint()` automatically caches the remote response +body. If you need fresh data, pass `forceRefresh: true`. + +### Pagination Handling + +The `EntityCollection` object encapsulates the `next` cursor for seamless +pagination: + +```php +$results = []; +$cursor = null; + +do { + $page = $federationTools->federationDiscovery()->fetchFromCollectionEndpoint( + $endpoint, + ['limit' => 100, 'from' => $cursor] + ); + + $results = array_merge($results, $page->getEntities()); + $cursor = $page->getNextPageToken(); +} while ($cursor !== null); +``` + +--- + +## Server-Side Implementation + +If you are implementing your own `federation_collection_endpoint`, the library +provides high-level building blocks to handle filtering, sorting, and +pagination. + +### The Pipeline Pattern + +The recommended implementation follows this pipeline: +**Discover → Filter → Sort → Paginate → Serialize**. + +```php +public function __invoke(ServerRequestInterface $request): ResponseInterface +{ + $params = $request->getQueryParams(); + + // 1. Load entities from the Federation traversal cache + $collection = $this->federationTools->federationDiscovery()->discover($this->trustAnchorId); + + // 2. Filter (Standard OpenID Federation criteria) + // Supports 'entity_type' (OR), 'trust_mark_type' (AND), and 'query' (Search) + $collection->filter($params); + + // 3. Sort (By nested metadata claims) + if (isset($params['sort_by'])) { + $path = explode('.', $params['sort_by']); // e.g. "metadata.federation_entity.display_name" + $collection->sort([$path], $params['sort_dir'] ?? 'asc'); + } + + // 4. Paginate (Using opaque cursors) + $collection->paginate( + limit: (int) ($params['limit'] ?? 100), + from: $params['from'] ?? null + ); + + // 5. Serialize to spec-compliant array + return new JsonResponse($collection->toCollectionEndpointResponseArray()); +} +``` + +### Filtering Technical Details + +| Criteria | Behavior | Fields Checked | +| :--- | :--- | :--- | +| `entity_type` | **OR** (Any match) | Metadata keys | +| `trust_mark_type` | **AND** (All must match) | `trust_marks[].id` | +| `query` | **Case-Insensitive** | `sub`, `display_name`, `organization_name` | + +### Sorting Technical Details + +The `sort()` method accepts an array of claim paths relative to the +**JWT payload root**. When sorting by metadata claims, you must explicitly +include the `metadata` prefix: + +```php +$collection->sort([ + ['metadata', 'openid_provider', 'display_name'], // Primary (Metadata) + ['metadata', 'federation_entity', 'display_name'], // Fallback 1 (Metadata) + ['sub'] // Fallback 2 (Entity ID root claim) +], 'asc'); +``` + +--- + +## Serialized Response Format + +The `toCollectionEndpointResponseArray()` method produces a structure compatible +with the OpenID Federation specification: + +```json +{ + "entities": [ + { + "entity_id": "https://idp.example.org/", + "entity_types": ["openid_provider"], + "ui_infos": { + "openid_provider": { + "display_name": "Example IDP" + } + }, + "trust_marks": [ + { "id": "https://example.org/marks/certified", "trust_mark": "..." } + ] + } + ], + "next": "aHR0cHM6Ly9pZHAuZXhhbXBsZS5vcmcv", + "last_updated": 1745410000 +} +``` diff --git a/specifications/openid-federation-entity-collection.md b/specifications/openid-federation-entity-collection-1_0.md similarity index 82% rename from specifications/openid-federation-entity-collection.md rename to specifications/openid-federation-entity-collection-1_0.md index fbcc516..4e0bffb 100644 --- a/specifications/openid-federation-entity-collection.md +++ b/specifications/openid-federation-entity-collection-1_0.md @@ -13,7 +13,7 @@ title: OpenID Federation Entity Collection Endpoint 1.0 - draft 00 viewport: initial-scale=1.0 --- - openid-federation-entity-collection March 2026 + openid-federation-entity-collection April 2026 ---------- ------------------------------------- ------------ Zachmann Standards Track \[Page\] @@ -21,7 +21,7 @@ Workgroup: : individual Published: -: 27 March 2026 +: 28 April 2026 Author: @@ -65,7 +65,7 @@ This specification acts as an extension to \[[OpenID.Federation](#OpenID.Federat - [3.4.1](#section-3.4.1).  [Response Format](#name-response-format) - - [3.4.2](#section-3.4.2).  [Response Claims](#name-response-claims) + - [3.4.2](#section-3.4.2).  [Error Response Format](#name-error-response-format) - [4](#section-4).  [Claims Languages and Scripts](#name-claims-languages-and-script) @@ -175,31 +175,31 @@ The following is a non-normative example of an Entity Configuration payload, for When client authentication is not used, the request to the `federation_collection_endpoint` MUST be an HTTP request using the GET method with the following query parameters, encoded in `application/x-www-form-urlencoded` format:[¶](#section-3.3.1-1) -- **from**: (OPTIONAL) If this parameter is present, the resulting list MUST be the subset of the overall ordered response starting from this pointer. This parameter MUST be copied from the `next` response parameter of a previous request. If the pointer in this parameter is not or not longer known to the responder, it MUST use the HTTP status code 404 and the content type `application/json` with the error code `page_not_found`.\ - 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`.[¶](#section-3.3.1-2.1.1) +- **from**: (OPTIONAL) If this parameter is present, the resulting list MUST be the subset of the overall ordered response starting from this pointer. This parameter MUST be copied from the `next` response parameter of a previous request. If the pointer in this parameter is not or not longer known to the responder, it MUST return an error response with the error code `page_not_found` as defined in [Error Response Format](#error-response-format).\ + If the responder does not support this feature, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.1.1) -- **limit**: (OPTIONAL) Requested number of results included in the response. If this parameter is present, the number of results in the returned list MUST NOT be greater than the minimum of the responder's upper limit and the value of this parameter. If this parameter is not present the server MUST fall back on the upper limit.\ - 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`.[¶](#section-3.3.1-2.2.1) +- **limit**: (OPTIONAL) Requested number of results included in the response. If this parameter is present, the number of results in the returned list MUST NOT be greater than the minimum of the responder\'s upper limit and the value of this parameter. If this parameter is not present the server MUST fall back on the upper limit.\ + If the responder does not support this feature, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.2.1) -- **entity_type**: (OPTIONAL) The value of this parameter is an Entity Type Identifier. The result MUST be filtered to include only those entities that include the specified Entity Type. When multiple `entity_type` parameters are present, for example `entity_type=openid_provider&entity_type=openid_relying_party`, the result MUST be filtered to include all Entities that include any of the specified Entity Types. 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`.[¶](#section-3.3.1-2.3.1) +- **entity_type**: (OPTIONAL) The value of this parameter is an Entity Type Identifier. The result MUST be filtered to include only those entities that include the specified Entity Type. When multiple `entity_type` parameters are present, for example `entity_type=openid_provider&entity_type=openid_relying_party`, the result MUST be filtered to include all Entities that include any of the specified Entity Types. If the responder does not support this feature, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.3.1) - **trust_mark_type**: (OPTIONAL) The value of this parameter is a Trust Mark Type Identifier. The result MUST be filtered to include only Entities that publish a Trust Mark of this Trust Mark Type in their Entity Configuration and that Trust Mark MUST be verified by the responder. The responder SHOULD verify the Trust Mark using the same Trust Anchor that is used to collect the Entities. When multiple `trust_mark_type` parameters are present, the result MUST be filtered to include only Entities that have a Trust Mark for all the specified Trust Mark Types.\ - If the responder does not support this feature, it MUST use the HTTP status code 400 and set the content type to `application/json`, with the error code `unsupported_parameter`.[¶](#section-3.3.1-2.4.1) + If the responder does not support this feature, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.4.1) -- **trust_anchor**: (RECOMMENDED) The Trust Anchor that the collection endpoint MUST use when collecting Entities. The value is an Entity Identifier. If omitted, the responder sets this parameter to its own Entity Identifier. If the responder does not have a defined Entity Identifier, it MUST use the HTTP status code 400 and set the content type to `application/json`, with the error code `invalid_request`.[¶](#section-3.3.1-2.5.1) +- **trust_anchor**: (RECOMMENDED) The Trust Anchor that the collection endpoint MUST use when collecting Entities. The value is an Entity Identifier. If omitted, the responder sets this parameter to its own Entity Identifier. If the responder does not have a defined Entity Identifier, it MUST return an error response with the error code `invalid_request` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.5.1) - **query**: (OPTIONAL) The value of this parameter is used by the responder to filter down the list of returned Entities to only entities that match this parameter value. It is entirely up to the responder to define when an Entity matches the query.\ - If the responder does not support this feature, it SHOULD use the HTTP status code 400 and the content type `application/json`, with the error code `unsupported_parameter`.[¶](#section-3.3.1-2.6.1) + If the responder does not support this feature, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.6.1) - **entity_claims**: (OPTIONAL) Array of claims to be included in the Entity Info Object included in the response for each collected Entity.\ If this parameter is NOT present it is at the discretion of the responder which claims are included or not.\ If this parameter is present and it is NOT an empty array, each Entity Info Object that represents an Entity MUST include the requested claims unless a specific claim is not available for that Entity. Also Claims that are optional to return and not present in the array MUST NOT be included in the Entity Info.\ - If the responder does not support a requested claim, it MUST use the HTTP status code 400 and set the content type to `application/json`, with the error code `unsupported_parameter`.[¶](#section-3.3.1-2.7.1) + If the responder does not support a requested claim, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.7.1) - **ui_claims**: (OPTIONAL) Array of claims to be included in the Entity Type UI Info Object included in the response for each returned Entity.\ If this parameter is NOT present it is at the discretion of the responder which claims are included or not.\ If this parameter is present and it is NOT an empty array, each Entity Type UI Info Object MUST include the requested claims unless a specific claim is not available for that Entity and Entity Type.\ - If the responder does not support a requested claim, it MUST use the HTTP status code 400 and set the content type to `application/json`, with the error code `unsupported_parameter`.[¶](#section-3.3.1-2.8.1) + If the responder does not support a requested claim, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.8.1) When Client authentication is used, the request MUST be an HTTP request using the POST method, with the parameters passed in the POST body.[¶](#section-3.3.1-3) @@ -218,53 +218,47 @@ The following is a non-normative example of a collection request:[¶](#section-3 A successful response MUST use the HTTP status code 200 and the content type `application/json`.[¶](#section-3.4.1-1) -The response is a JSON object as described below.[¶](#section-3.4.1-2) +The response is a JSON object with the following claims:[¶](#section-3.4.1-2) -If the response is negative, it will be a JSON object and the content type MUST be `application/json` and use the errors defined here or in \[[OpenID.Federation](#OpenID.Federation)\].[¶](#section-3.4.1-3) +- **entities**: (REQUIRED) Array of JSON objects, each representing a Federation Entity as described in [Entity Info](#entity-info). The list of Entities MUST only contain Entities that are in line with the requested parameters. The responder MAY also filter down the list further at its own discretion.[¶](#section-3.4.1-3.1) +- **next**: (OPTIONAL) An opaque pointer to the next page in the result list. This attribute is REQUIRED when additional results are available beyond those included in the `entities` array. To content of this attribute is entirely up to the responder and its pagination implementation strategy.[¶](#section-3.4.1-3.2) +- **last_updated**: (RECOMMENDED) Number. Time when the responder last updated the result list. This is expressed as Seconds Since the Epoch, per \[[RFC7519](#RFC7519)\]. If the `last_updated` time changes between paginated calls, this might be an indication for the client that it might have received outdated information in a previous call.[¶](#section-3.4.1-3.3) -#### [3.4.2.](#section-3.4.2) [Response Claims](#name-response-claims) +Additional claims MAY be defined and used in conjunction with the claims above.[¶](#section-3.4.1-4) -The claims in the entity collection response are:[¶](#section-3.4.2-1) +##### [3.4.1.1.](#section-3.4.1.1) [Entity Info](#name-entity-info) -- **entities**: (REQUIRED) Array of JSON objects, each representing a Federation Entity as described in [Entity Info](#entity-info). The list of Entities MUST only contain Entities that are in line with the requested parameters. The responder MAY also filter down the list further at its own discretion.[¶](#section-3.4.2-2.1) -- **next**: (OPTIONAL) An opaque pointer to the next page in the result list. This attribute is REQUIRED when additional results are available beyond those included in the `entities` array. To content of this attribute is entirely up to the responder and its pagination implementation strategy.[¶](#section-3.4.2-2.2) -- **last_updated**: (RECOMMENDED) Number. Time when the responder last updated the result list. This is expressed as Seconds Since the Epoch, per \[[RFC7519](#RFC7519)\]. If the `last_updated` time changes between paginated calls, this might be an indication for the client that it might have received outdated information in a previous call.[¶](#section-3.4.2-2.3) +Each JSON Object in the returned `entities` array MAY contain the following claims:[¶](#section-3.4.1.1-1) -Additional claims MAY be defined and used in conjunction with the claims above.[¶](#section-3.4.2-3) +- **entity_id**: (REQUIRED) The Entity Identifier for the subject entity of the current record.[¶](#section-3.4.1.1-2.1) -##### [3.4.2.1.](#section-3.4.2.1) [Entity Info](#name-entity-info) - -Each JSON Object in the returned `entities` array MAY contain the following claims:[¶](#section-3.4.2.1-1) - -- **entity_id**: (REQUIRED) The Entity Identifier for the subject entity of the current record.[¶](#section-3.4.2.1-2.1) - -- **entity_types**: (RECOMMENDED) Array of string Entity Type Identifiers. If present this claim MUST contain all Entity Type Identifiers of the subject\'s Entity the responder knows about.[¶](#section-3.4.2.1-2.2) +- **entity_types**: (RECOMMENDED) Array of string Entity Type Identifiers. If present this claim MUST contain all Entity Type Identifiers of the subject\'s Entity the responder knows about.[¶](#section-3.4.1.1-2.2) - **ui_infos**: (OPTIONAL) JSON Object containing information intended to be displayed to the user for each entity type as described in [UI Infos](#ui-infos).\ - If the request contains the `entity_type` parameter, the UI Infos Object MUST only contain Entity Type Identifiers that are among the ones requested, with the exception of the `federation_entity` Entity Type Identifier, which MAY also appear if not explicitly requested.[¶](#section-3.4.2.1-2.3.1) + If the request contains the `entity_type` parameter, the UI Infos Object MUST only contain Entity Type Identifiers that are among the ones requested, with the exception of the `federation_entity` Entity Type Identifier, which MAY also appear if not explicitly requested.[¶](#section-3.4.1.1-2.3.1) -- **trust_marks**: (OPTIONAL) Array of objects, each representing a Trust Mark, as defined in Section 3 of \[[OpenID.Federation](#OpenID.Federation)\].[¶](#section-3.4.2.1-2.4.1) +- **trust_marks**: (OPTIONAL) Array of objects, each representing a Trust Mark, as defined in Section 3 of \[[OpenID.Federation](#OpenID.Federation)\].[¶](#section-3.4.1.1-2.4.1) -Additional claims MAY be defined and used in conjunction with the claims above.[¶](#section-3.4.2.1-3) +Additional claims MAY be defined and used in conjunction with the claims above.[¶](#section-3.4.1.1-3) -###### [3.4.2.1.1.](#section-3.4.2.1.1) [UI Infos](#name-ui-infos) +###### [3.4.1.1.1.](#section-3.4.1.1.1) [UI Infos](#name-ui-infos) -UI Infos is a JSON Object containing UI-related information about a single Entity, but differentiated by its Entity Types.[¶](#section-3.4.2.1.1-1) +UI Infos is a JSON Object containing UI-related information about a single Entity, but differentiated by its Entity Types.[¶](#section-3.4.1.1.1-1) -Each member name of the JSON object is an Entity Type Identifier and each value is an Entity Type UI Info Object as defined in [Entity Type UI Info](#entity-type-ui-info).[¶](#section-3.4.2.1.1-2) +Each member name of the JSON object is an Entity Type Identifier and each value is an Entity Type UI Info Object as defined in [Entity Type UI Info](#entity-type-ui-info).[¶](#section-3.4.1.1.1-2) -###### [3.4.2.1.1.1.](#section-3.4.2.1.1.1) [Entity Type UI Info](#name-entity-type-ui-info) +###### [3.4.1.1.1.1.](#section-3.4.1.1.1.1) [Entity Type UI Info](#name-entity-type-ui-info) -Entity Type UI Info is a JSON Object containing UI-related information about a single Entity Type of an Entity.[¶](#section-3.4.2.1.1.1-1) +Entity Type UI Info is a JSON Object containing UI-related information about a single Entity Type of an Entity.[¶](#section-3.4.1.1.1.1-1) -All Claims specified in section 5.2.2 \"Informational Metadata Extensions\" of \[[OpenID.Federation](#OpenID.Federation)\] MAY be used.[¶](#section-3.4.2.1.1.1-2) +All Claims specified in section 5.2.2 \"Informational Metadata Extensions\" of \[[OpenID.Federation](#OpenID.Federation)\] MAY be used.[¶](#section-3.4.1.1.1.1-2) -Additional Claims MAY be defined and used in conjunction with the Claims above.[¶](#section-3.4.2.1.1.1-3) +Additional Claims MAY be defined and used in conjunction with the Claims above.[¶](#section-3.4.1.1.1.1-3) -##### [3.4.2.2.](#section-3.4.2.2) [Example Response](#name-example-response) +##### [3.4.1.2.](#section-3.4.1.2) [Example Response](#name-example-response) { - "federation_entities": [ + "entities": [ { "entity_id": "https://green.example.com", "entity_types": [ @@ -303,7 +297,33 @@ Additional Claims MAY be defined and used in conjunction with the Claims above.[ ] } -[¶](#section-3.4.2.2-1) +[¶](#section-3.4.1.2-1) + +#### [3.4.2.](#section-3.4.2) [Error Response Format](#name-error-response-format) + +If the request was malformed or an error occurred during the processing of the request, the response body MUST be a JSON object with the content type `application/json`. In compliance with \[[RFC6749](#RFC6749)\] and \[[OpenID.Federation](#OpenID.Federation)\], the following standardized error format MUST be used:[¶](#section-3.4.2-1) + +- **error**: (REQUIRED) Error codes in the IANA \"OAuth Extensions Error Registry\" \[[IANA.OAuth.Parameters](#IANA.OAuth.Parameters)\] MAY be used. In particular, these existing error codes are used by this specification:[¶](#section-3.4.2-2.1.1) + + - **unsupported_parameter**: The server does not support a requested parameter. The HTTP response status code SHOULD be 400 (Bad Request).[¶](#section-3.4.2-2.1.2.1) + - **invalid_request**: The request is incomplete or does not comply with current specifications. The HTTP response status code SHOULD be 400 (Bad Request).\ + \ + In addition the following error codes defined by this specification MAY be used:[¶](#section-3.4.2-2.1.2.2) + - **page_not_found**: The pagination pointer provided in the `from` parameter is not or no longer known to the responder. The HTTP response status code SHOULD be 404 (Not Found).[¶](#section-3.4.2-2.1.2.3) + +- **error_description**: (REQUIRED) Human-readable text providing additional information used to assist the developer in understanding the error that occurred.[¶](#section-3.4.2-2.2) + +The following is a non-normative example of an error response:[¶](#section-3.4.2-3) + + 400 Bad Request + Content-Type: application/json + + { + "error": "unsupported_parameter", + "error_description": "The 'limit' parameter is not supported by this endpoint." + } + +[¶](#section-3.4.2-4) ## [4.](#section-4) [Claims Languages and Scripts](#name-claims-languages-and-script) @@ -366,22 +386,26 @@ The responder is free to restrict the scope of its Entity Collection Endpoint, s ## [6.](#section-6) [Security Considerations](#name-security-considerations) -In additional to the considerations below, the security considerations of OpenID Federation 1.0 \[[OpenID.Federation](#OpenID.Federation)\] apply to this specification.[¶](#section-6-1) +In addition to the considerations below, the security considerations of OpenID Federation 1.0 \[[OpenID.Federation](#OpenID.Federation)\] apply to this specification.[¶](#section-6-1) ### [6.1.](#section-6.1) [Unsigned Response](#name-unsigned-response) -The response from the Entity Collection Endpoint is not signed and the obtained information MUST be considered as informational. To verify an Entity proper trust validation according to OpenID Federation 1.0 \[[OpenID.Federation](#OpenID.Federation)\] still MUST be done.[¶](#section-6.1-1) +The response from the Entity Collection Endpoint is not signed and the obtained information MUST be considered as informational. To verify an Entity, proper trust validation according to OpenID Federation 1.0 \[[OpenID.Federation](#OpenID.Federation)\] still MUST be done.[¶](#section-6.1-1) It is also noted that Trust Marks returned in the response MAY not be verified and clients MUST consider them as not yet verified.[¶](#section-6.1-2) ## [7.](#section-7) [Normative References](#name-normative-references) +\[IANA.OAuth.Parameters\] +: IANA, \"OAuth Parameters\", 25 March 2026, \<\>. +: + \[OpenID.Core\] : Sakimura, N., Bradley, J., Jones, M., de Medeiros, B., and C. Mortimore, \"OpenID Connect Core 1.0 incorporating errata set 2\", 15 December 2023, \<\>. : \[OpenID.Federation\] -: Ed., R. H., Jones, M. B., Solberg, A., Bradley, J., Marco, G. D., and V. Dzhuvinov, \"OpenID Federation 1.0\", 24 October 2024, \<\>. +: Ed., R. H., Jones, M. B., Solberg, A., Bradley, J., Marco, G. D., and V. Dzhuvinov, \"OpenID Federation 1.0\", 17 February 2026, \<\>. : \[RFC2119\] @@ -406,7 +430,7 @@ It is also noted that Trust Marks returned in the response MAY not be verified a ## [Appendix A.](#appendix-A) [Notices](#name-notices) -Copyright (c) 2025 The OpenID Foundation.[¶](#appendix-A-1) +Copyright (c) 2026 The OpenID Foundation.[¶](#appendix-A-1) The OpenID Foundation (OIDF) grants to any Contributor, developer, implementer, or other interested party a non-exclusive, royalty free, worldwide copyright license to reproduce, prepare derivative works from, distribute, perform and display, this Implementers Draft, Final Specification, or Final Specification Incorporating Errata Corrections solely for the purposes of (i) developing specifications, and (ii) implementing Implementers Drafts, Final Specifications, and Final Specification Incorporating Errata Corrections based on such documents, provided that attribution be made to the OIDF as the source of the material, but that such attribution does not indicate an endorsement by the OIDF.[¶](#appendix-A-2) @@ -414,7 +438,7 @@ The technology described in this specification was made available from contribut ## [Appendix B.](#appendix-B) [Acknowledgements](#name-acknowledgements) -We would like to thank the following individuals for their contributions to this specification: Niels van Dijk, Michael Fraser, Łukasz Jaromin, Michael B. Jones, Giuseppe De Marco, Stefan Santesson, Phil Smart, Zacharias Törnblom, and the Geant Trust & Identity Incubator of Geant5-2.[¶](#appendix-B-1) +We would like to thank the following individuals for their contributions to this specification: Niels van Dijk, Michael Fraser, Marko Ivančić, Łukasz Jaromin, Michael B. Jones, Giuseppe De Marco, Stefan Santesson, Phil Smart, Zacharias Törnblom, and the Geant Trust & Identity Incubator of Geant5-2.[¶](#appendix-B-1) ## [Appendix C.](#appendix-C) [Document History](#name-document-history) diff --git a/specifications/update-specs.sh b/specifications/update-specs.sh index 5c8f0c8..2c9621d 100755 --- a/specifications/update-specs.sh +++ b/specifications/update-specs.sh @@ -13,7 +13,7 @@ URLS=( # OpenID specifications "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html" "https://openid.net/specs/openid-federation-1_0.html" - #"https://zachmann.github.io/openid-federation-entity-collection/main.html" + "https://openid.net/specs/openid-federation-entity-collection-1_0.html" "https://openid.net/specs/openid-connect-core-1_0.html" "https://openid.net/specs/openid-connect-discovery-1_0.html" "https://openid.net/specs/openid-connect-rpinitiated-1_0.html" diff --git a/src/Codebooks/ClaimsEnum.php b/src/Codebooks/ClaimsEnum.php index b99c6f1..1d4a581 100644 --- a/src/Codebooks/ClaimsEnum.php +++ b/src/Codebooks/ClaimsEnum.php @@ -173,6 +173,14 @@ enum ClaimsEnum: string case Expiration_Date = 'expirationDate'; + case Entities = 'entities'; + + case EntityId = 'entity_id'; + + case EntityTypes = 'entity_types'; + + case FederationCollectionEndpoint = 'federation_collection_endpoint'; + case FederationFetchEndpoint = 'federation_fetch_endpoint'; case FederationListEndpoint = 'federation_list_endpoint'; @@ -253,6 +261,8 @@ enum ClaimsEnum: string case Keys = 'keys'; + case LastUpdated = 'last_updated'; + case Length = 'length'; case Locale = 'locale'; @@ -272,6 +282,8 @@ enum ClaimsEnum: string case Name = 'name'; + case Next = 'next'; + case Nonce = 'nonce'; case NonceEndpoint = 'nonce_endpoint'; @@ -430,6 +442,9 @@ enum ClaimsEnum: string // TransactionCode case TxCode = 'tx_code'; + // UI Infos + case UiInfos = 'ui_infos'; + // UserInterfaceLocalesSupported case UiLocalesSupported = 'ui_locales_supported'; diff --git a/src/Exceptions/EntityDiscoveryException.php b/src/Exceptions/EntityDiscoveryException.php new file mode 100644 index 0000000..786d95d --- /dev/null +++ b/src/Exceptions/EntityDiscoveryException.php @@ -0,0 +1,9 @@ +maxCacheDurationDecorator = $this->dateIntervalDecoratorFactory()->build($maxCacheDuration); $this->timestampValidationLeewayDecorator = $this->dateIntervalDecoratorFactory() ->build($timestampValidationLeeway); $this->maxTrustChainDepth = min(20, max(1, $maxTrustChainDepth)); + $this->maxDiscoveryDepth = max(1, $maxDiscoveryDepth); $this->cacheDecorator = is_null($cache) ? null : $this->cacheDecoratorFactory()->build($cache); $this->httpClientDecorator = $this->httpClientDecoratorFactory()->build($client); } @@ -321,6 +347,84 @@ public function trustMarkFetcher(): TrustMarkFetcher } + public function subordinateListingFetcher(): SubordinateListingFetcher + { + return $this->subordinateListingFetcher ??= new SubordinateListingFetcher( + $this->artifactFetcher(), + $this->helpers(), + $this->maxCacheDurationDecorator(), + $this->logger, + ); + } + + + public function entityCollectionStore(): EntityCollectionStoreInterface + { + if ($this->entityCollectionStore instanceof Federation\EntityCollection\EntityCollectionStoreInterface) { + return $this->entityCollectionStore; + } + + return $this->entityCollectionStore = + $this->cacheDecorator() instanceof \SimpleSAML\OpenID\Decorators\CacheDecorator ? + new CacheEntityCollectionStore( + $this->cacheDecorator(), + $this->helpers(), + $this->logger, + ) : + new InMemoryEntityCollectionStore(); + } + + + public function entityCollectionFactory(): EntityCollectionFactory + { + return $this->entityCollectionFactory ??= new EntityCollectionFactory( + $this->entityCollectionFilter(), + $this->entityCollectionSorter(), + $this->entityCollectionPaginator(), + ); + } + + + public function federationDiscovery(): FederationDiscovery + { + if (!$this->federationDiscovery instanceof \SimpleSAML\OpenID\Federation\FederationDiscovery) { + $this->federationDiscovery = new FederationDiscovery( + $this->entityStatementFetcher(), + $this->subordinateListingFetcher(), + $this->entityCollectionStore(), + $this->maxCacheDurationDecorator(), + $this->entityCollectionFactory(), + $this->artifactFetcher(), + $this->helpers(), + $this->logger, + $this->maxDiscoveryDepth, + ); + } + + return $this->federationDiscovery; + } + + + public function entityCollectionFilter(): EntityCollectionFilter + { + return $this->entityCollectionFilter ??= new EntityCollectionFilter($this->helpers()); + } + + + public function entityCollectionSorter(): EntityCollectionSorter + { + return $this->entityCollectionSorter ??= new EntityCollectionSorter($this->helpers()); + } + + + public function entityCollectionPaginator(): EntityCollectionPaginator + { + return $this->entityCollectionPaginator ??= new EntityCollectionPaginator( + $this->helpers(), + ); + } + + public function helpers(): Helpers { return $this->helpers ??= new Helpers(); diff --git a/src/Federation/EntityCollection.php b/src/Federation/EntityCollection.php new file mode 100644 index 0000000..2e30579 --- /dev/null +++ b/src/Federation/EntityCollection.php @@ -0,0 +1,156 @@ +> $entities Keyed by entity ID, + * value is JWT payload + */ + public function __construct( + protected readonly EntityCollectionFilter $entityCollectionFilter, + protected readonly EntityCollectionSorter $entityCollectionSorter, + protected readonly EntityCollectionPaginator $entityCollectionPaginator, + protected array $entities, + protected ?string $nextPageToken = null, + protected ?int $lastUpdated = null, + ) { + } + + + /** + * @return array> + */ + public function getEntities(): array + { + return $this->entities; + } + + + public function getLastUpdated(): ?int + { + return $this->lastUpdated; + } + + + /** + * @return array{ + * entities: array>, + * next?: string, + * last_updated?: int + * } + */ + public function toCollectionEndpointResponseArray(): array + { + $entities = []; + foreach ($this->entities as $payload) { + $metadata = $payload[ClaimsEnum::Metadata->value] ?? []; + if (!is_array($metadata)) { + $metadata = []; + } + + $entry = [ + ClaimsEnum::EntityId->value => $payload[ClaimsEnum::Sub->value] ?? '', + ClaimsEnum::EntityTypes->value => array_keys($metadata), + ]; + + if ($metadata !== []) { + $entry[ClaimsEnum::UiInfos->value] = $metadata; + } + + if (isset($payload[ClaimsEnum::TrustMarks->value])) { + $entry[ClaimsEnum::TrustMarks->value] = $payload[ClaimsEnum::TrustMarks->value]; + } + + $entities[] = $entry; + } + + $data = [ + ClaimsEnum::Entities->value => $entities, + ]; + + if (!is_null($this->nextPageToken)) { + $data[ClaimsEnum::Next->value] = $this->nextPageToken; + } + + if (!is_null($this->lastUpdated)) { + $data[ClaimsEnum::LastUpdated->value] = $this->lastUpdated; + } + + return $data; + } + + + /** + * Apply filters to the collection. Supported criteria keys: + * - entity_type: array of entity types to include + * (e.g. ['openid_relying_party']) + * - trust_mark_type: array of trust mark types to include + * (e.g. ['https://example.com/marks/approved']) + * - query: string to search for in display_name, organization_name, + * and entity_id (case-insensitive) + * + * @param array{ + * entity_type?: string[], + * trust_mark_type?: string[], + * query?: string, + * } $criteria + * @return $this + */ + public function filter(array $criteria): static + { + $this->entities = $this->entityCollectionFilter->filter($this->entities, $criteria); + + return $this; + } + + + /** + * @param non-empty-array $claimPaths + * @param 'asc'|'desc' $sortOrder + * @return $this + */ + public function sort(array $claimPaths, string $sortOrder): static + { + $this->entities = $this->entityCollectionSorter->sort( + $this->entities, + $claimPaths, + $sortOrder, + ); + + return $this; + } + + + /** + * @param positive-int $limit Maximum number of entries to return + * @param string|null $from Opaque cursor (base64 encoded entity ID to start AFTER) + */ + public function paginate(int $limit, ?string $from = null): static + { + [ + 'entities' => $this->entities, + 'next' => $this->nextPageToken, + ] = $this->entityCollectionPaginator->paginate( + $this->entities, + $limit, + $from, + ); + + return $this; + } + + + public function getNextPageToken(): ?string + { + return $this->nextPageToken; + } +} diff --git a/src/Federation/EntityCollection/CacheEntityCollectionStore.php b/src/Federation/EntityCollection/CacheEntityCollectionStore.php new file mode 100644 index 0000000..e38cd6d --- /dev/null +++ b/src/Federation/EntityCollection/CacheEntityCollectionStore.php @@ -0,0 +1,148 @@ +cacheDecorator->set( + $entities, + $ttl, + self::KEY_FEDERATED_ENTITIES, + $trustAnchorId, + ); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to store entities in cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'entities' => $entities, + 'exception_message' => $throwable->getMessage(), + ]); + } + } + + + /** + * @inheritDoc + */ + public function get(string $trustAnchorId): ?array + { + try { + $cached = $this->cacheDecorator->get(null, self::KEY_FEDERATED_ENTITIES, $trustAnchorId); + + if (!is_array($cached)) { + return null; + } + + /** @var array> $cached */ + return $cached; + } catch (Throwable $throwable) { + $this->logger?->error('Unable to retrieve entities from cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'exception_message' => $throwable->getMessage(), + ]); + return null; + } + } + + + /** + * @inheritDoc + */ + public function clear(string $trustAnchorId): void + { + try { + $this->cacheDecorator->delete(self::KEY_FEDERATED_ENTITIES, $trustAnchorId); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to clear entities from cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'exception_message' => $throwable->getMessage(), + ]); + } + } + + + /** + * @inheritDoc + */ + public function storeLastUpdated(string $trustAnchorId, int $timestamp, int $ttl): void + { + try { + $this->cacheDecorator->set( + (string)$timestamp, + $ttl, + self::KEY_LAST_UPDATED, + $trustAnchorId, + ); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to store last updated timestamp in cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'timestamp' => $timestamp, + 'exception_message' => $throwable->getMessage(), + ]); + } + } + + + /** + * @inheritDoc + */ + public function getLastUpdated(string $trustAnchorId): ?int + { + try { + $lastUpdated = $this->cacheDecorator->get(null, self::KEY_LAST_UPDATED, $trustAnchorId); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to retrieve last updated timestamp from cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'exception_message' => $throwable->getMessage(), + ]); + return null; + } + + if (is_int($lastUpdated)) { + return $lastUpdated; + } + + return null; + } + + + /** + * @inheritDoc + */ + public function clearLastUpdated(string $trustAnchorId): void + { + try { + $this->cacheDecorator->delete(self::KEY_LAST_UPDATED, $trustAnchorId); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to clear last updated timestamp from cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'exception_message' => $throwable->getMessage(), + ]); + } + } +} diff --git a/src/Federation/EntityCollection/EntityCollectionFilter.php b/src/Federation/EntityCollection/EntityCollectionFilter.php new file mode 100644 index 0000000..f6a983b --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionFilter.php @@ -0,0 +1,130 @@ +> $entities The list of entities + * to be filtered. Each entity is expected to be an associative array. + * @param array{ + * entity_type?: string[], + * trust_mark_type?: string[], + * query?: string, + * } $criteria The array of filtering criteria. It may contain: + * - 'entity_type': An array of entity types to filter by. + * - 'trust_mark_type': An array of trust mark types to filter by. + * - 'query': A string used to perform a case-insensitive search on + * specific fields. + * @return array> The filtered list of entities + * that match all provided criteria. + */ + public function filter(array $entities, array $criteria): array + { + // 1. entity_type + if (isset($criteria['entity_type']) && $criteria['entity_type'] !== []) { + $types = $criteria['entity_type']; + $entities = array_filter($entities, function (array $payload) use ($types): bool { + $metadata = $payload[ClaimsEnum::Metadata->value] ?? null; + if (!is_array($metadata)) { + return false; + } + + foreach ($types as $type) { + if (isset($metadata[$type])) { + return true; + } + } + + return false; + }); + } + + // 2. trust_mark_type + if (isset($criteria['trust_mark_type']) && $criteria['trust_mark_type'] !== []) { + $criteriaTrustMarkTypes = $criteria['trust_mark_type']; + $entities = array_filter($entities, function (array $payload) use ($criteriaTrustMarkTypes): bool { + $entityTrustMarks = $payload[ClaimsEnum::TrustMarks->value] ?? null; + if (!is_array($entityTrustMarks)) { + return false; + } + + $entityTrustMarkTypes = []; + foreach ($entityTrustMarks as $mark) { + if (is_array($mark) && isset($mark[ClaimsEnum::TrustMarkType->value])) { + $entityTrustMarkTypes[] = $mark[ClaimsEnum::TrustMarkType->value]; + } + } + + foreach ($criteriaTrustMarkTypes as $tmType) { + if (!in_array($tmType, $entityTrustMarkTypes, true)) { + return false; + } + } + + return true; + }); + } + + // 3. query + if (isset($criteria['query']) && $criteria['query'] !== '') { + $q = mb_strtolower($criteria['query']); + $entities = array_filter($entities, function (array $payload) use ($q): bool { + $sub = is_string($payload[ClaimsEnum::Sub->value] ?? null) ? + mb_strtolower($payload[ClaimsEnum::Sub->value]) : + ''; + if ($sub !== '' && str_contains($sub, $q)) { + return true; + } + + $metadata = $payload[ClaimsEnum::Metadata->value] ?? null; + if (!is_array($metadata)) { + return false; + } + + // Check display_name or organization_name in any entity type + foreach ($metadata as $typePayload) { + if (!is_array($typePayload)) { + continue; + } + + $displayNameValue = $typePayload[ClaimsEnum::DisplayName->value] ?? ''; + $displayName = mb_strtolower(is_string($displayNameValue) ? $displayNameValue : ''); + if ($displayName !== '' && str_contains($displayName, $q)) { + return true; + } + + $orgNameValue = $typePayload[ClaimsEnum::OrganizationName->value] ?? ''; + $orgName = mb_strtolower(is_string($orgNameValue) ? $orgNameValue : ''); + if ($orgName !== '' && str_contains($orgName, $q)) { + return true; + } + } + + return false; + }); + } + + return $entities; + } +} diff --git a/src/Federation/EntityCollection/EntityCollectionPaginator.php b/src/Federation/EntityCollection/EntityCollectionPaginator.php new file mode 100644 index 0000000..614b3b9 --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionPaginator.php @@ -0,0 +1,53 @@ +> $entities The list of entities + * to be paginate, ordered (pre-sorted). + * @param positive-int $limit Maximum number of entries to return + * @param string|null $from Opaque cursor (base64 encoded entity ID to start AFTER) + * @return array{entities: array>, next: ?string} + */ + public function paginate(array $entities, int $limit, ?string $from = null): array + { + $keys = array_keys($entities); + $offset = 0; + + if (!is_null($from)) { + $fromId = $this->helpers->base64Url()->decode($from); + $index = array_search($fromId, $keys, true); + if ($index !== false) { + $offset = $index + 1; + } + } + + $pageItems = array_slice($entities, $offset, $limit, true); + $next = null; + + if ($offset + $limit < count($keys)) { + $lastIdInPage = array_key_last($pageItems); + if ($lastIdInPage !== null) { + $next = $this->helpers->base64Url()->encode((string)$lastIdInPage); + } + } + + return [ + ClaimsEnum::Entities->value => $pageItems, + ClaimsEnum::Next->value => $next, + ]; + } +} diff --git a/src/Federation/EntityCollection/EntityCollectionSorter.php b/src/Federation/EntityCollection/EntityCollectionSorter.php new file mode 100644 index 0000000..70f9881 --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionSorter.php @@ -0,0 +1,69 @@ +> $entities Keyed by entity ID + * @param non-empty-array $claimPaths Array of + * nested claim paths within the entity payload object + * (e.g. [['metadata', 'openid_provider', 'display_name'], ['metadata', 'federation_entity', 'display_name']]) + * @param 'asc'|'desc' $direction + * @return array> Sorted copy + */ + public function sort( + array $entities, + array $claimPaths, + string $direction = 'asc', + ): array { + if ($entities === []) { + return []; + } + + uasort($entities, function (array $a, array $b) use ($claimPaths, $direction): int { + foreach ($claimPaths as $claimPath) { + try { + $valA = $this->helpers->arr()->getNestedValue($a, ...$claimPath); + } catch (OpenIdException) { + // If the claim path doesn't exist, treat it as null + $valA = null; + } + + try { + $valB = $this->helpers->arr()->getNestedValue($b, ...$claimPath); + } catch (OpenIdException) { + // If the claim path doesn't exist, treat it as null + $valB = null; + } + + // Treat nulls or non-strings as empty strings for comparison + $strA = is_string($valA) ? $valA : ''; + $strB = is_string($valB) ? $valB : ''; + + $cmp = strcasecmp($strA, $strB); + + if ($cmp !== 0) { + return $direction === 'desc' ? -$cmp : $cmp; + } + } + + return 0; + }); + + return $entities; + } +} diff --git a/src/Federation/EntityCollection/EntityCollectionStoreInterface.php b/src/Federation/EntityCollection/EntityCollectionStoreInterface.php new file mode 100644 index 0000000..e8e9914 --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionStoreInterface.php @@ -0,0 +1,54 @@ +> $entities Keyed by entity ID, value is JWT payload + */ + public function store(string $trustAnchorId, array $entities, int $ttl): void; + + + /** + * Retrieve previously discovered entities. + * + * @param non-empty-string $trustAnchorId + * @return array>|null null when not found / expired + */ + public function get(string $trustAnchorId): ?array; + + + /** + * Remove stored entities (force re-discovery). + * + * @param non-empty-string $trustAnchorId + */ + public function clear(string $trustAnchorId): void; + + + /** + * Set the last update timestamp for a given trust anchor. + * + * @param non-empty-string $trustAnchorId + */ + public function storeLastUpdated(string $trustAnchorId, int $timestamp, int $ttl): void; + + + /** + * Get the last update timestamp for a given trust anchor. + * @param non-empty-string $trustAnchorId + */ + public function getLastUpdated(string $trustAnchorId): ?int; + + + /** + * Clear the last update timestamp for a given trust anchor. + */ + public function clearLastUpdated(string $trustAnchorId): void; +} diff --git a/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php b/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php new file mode 100644 index 0000000..13358c7 --- /dev/null +++ b/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php @@ -0,0 +1,62 @@ +>, expires: int}> */ + protected array $store = []; + + /** @var array */ + protected array $lastUpdatedStore = []; + + + public function store(string $trustAnchorId, array $entities, int $ttl): void + { + $this->store[$trustAnchorId] = [ + 'entities' => $entities, + 'expires' => time() + $ttl, + ]; + } + + + public function get(string $trustAnchorId): ?array + { + if (!isset($this->store[$trustAnchorId])) { + return null; + } + + if ($this->store[$trustAnchorId]['expires'] < time()) { + unset($this->store[$trustAnchorId]); + return null; + } + + return $this->store[$trustAnchorId]['entities']; + } + + + public function clear(string $trustAnchorId): void + { + unset($this->store[$trustAnchorId]); + } + + + public function storeLastUpdated(string $trustAnchorId, int $timestamp, int $ttl): void + { + $this->lastUpdatedStore[$trustAnchorId] = $timestamp; + } + + + public function getLastUpdated(string $trustAnchorId): ?int + { + return $this->lastUpdatedStore[$trustAnchorId] ?? null; + } + + + public function clearLastUpdated(string $trustAnchorId): void + { + unset($this->lastUpdatedStore[$trustAnchorId]); + } +} diff --git a/src/Federation/EntityStatement.php b/src/Federation/EntityStatement.php index 7ba635e..84d4be1 100644 --- a/src/Federation/EntityStatement.php +++ b/src/Federation/EntityStatement.php @@ -386,6 +386,54 @@ public function getFederationTrustMarkStatusEndpoint(): ?string } + /** + * @return ?non-empty-string + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function getFederationListEndpoint(): ?string + { + $federationListEndpoint = $this->helpers->arr()->getNestedValue( + $this->getPayload(), + ClaimsEnum::Metadata->value, + EntityTypesEnum::FederationEntity->value, + ClaimsEnum::FederationListEndpoint->value, + ); + + if (is_null($federationListEndpoint)) { + return null; + } + + return $this->helpers->type()->ensureNonEmptyString($federationListEndpoint); + } + + + /** + * @return ?non-empty-string + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function getFederationCollectionEndpoint(): ?string + { + $federationCollectionEndpoint = $this->helpers->arr()->getNestedValue( + $this->getPayload(), + ClaimsEnum::Metadata->value, + EntityTypesEnum::FederationEntity->value, + ClaimsEnum::FederationCollectionEndpoint->value, + ); + + if (is_null($federationCollectionEndpoint)) { + return null; + } + + return $this->helpers->type()->ensureNonEmptyString($federationCollectionEndpoint); + } + + /** * @return non-empty-string * @throws \SimpleSAML\OpenID\Exceptions\JwsException @@ -449,6 +497,8 @@ protected function validate(): void $this->getTrustMarkOwners(...), $this->getTrustMarkIssuers(...), $this->getFederationFetchEndpoint(...), + $this->getFederationListEndpoint(...), + $this->getFederationCollectionEndpoint(...), $this->getFederationTrustMarkEndpoint(...), $this->getFederationTrustMarkStatusEndpoint(...), ); diff --git a/src/Federation/Factories/EntityCollectionFactory.php b/src/Federation/Factories/EntityCollectionFactory.php new file mode 100644 index 0000000..8ef0f88 --- /dev/null +++ b/src/Federation/Factories/EntityCollectionFactory.php @@ -0,0 +1,40 @@ +> $entities Keyed by entity ID, + * value is JWT payload + */ + public function build( + array $entities, + ?int $lastUpdated, + ?string $nextPageToken = null, + ): EntityCollection { + return new EntityCollection( + $this->entityCollectionFilter, + $this->entityCollectionSorter, + $this->entityCollectionPaginator, + $entities, + $nextPageToken, + $lastUpdated, + ); + } +} diff --git a/src/Federation/FederationDiscovery.php b/src/Federation/FederationDiscovery.php new file mode 100644 index 0000000..486eb0c --- /dev/null +++ b/src/Federation/FederationDiscovery.php @@ -0,0 +1,296 @@ + payload map) in the federation rooted at $trustAnchorId. + * Results are stored in the EntityCollectionStoreInterface and returned. + * + * @param non-empty-string $trustAnchorId + * @param array $filters Passed through to + * SubordinateListingFetcher + * @param bool $forceRefresh If true, ignore stored entities and + * re-traverse the federation + */ + public function discover( + string $trustAnchorId, + array $filters = [], + bool $forceRefresh = false, + ): EntityCollection { + if (!$forceRefresh) { + $cachedEntities = $this->entityCollectionStore->get($trustAnchorId); + if (is_array($cachedEntities)) { + $this->logger?->debug( + 'Returning discovered entities from entity collection store.', + ['trustAnchorId' => $trustAnchorId], + ); + return $this->entityCollectionFactory->build( + $cachedEntities, + $this->entityCollectionStore->getLastUpdated($trustAnchorId), + ); + } + } + + $this->logger?->info( + 'Starting federation discovery.', + ['trustAnchorId' => $trustAnchorId, 'filters' => $filters], + ); + + $discoveredEntities = []; + $lastUpdated = null; + try { + // Step 1: Fetch TA config + $taConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($trustAnchorId); + + // Recursive traversal + $discoveredEntities = $this->traverse($trustAnchorId, $taConfig, $filters, 0, [], $forceRefresh); + + // Compute TTL: lowest of maxCacheDuration and TA expiry + $ttl = $this->maxCacheDurationDecorator->lowestInSecondsComparedToExpirationTime( + $taConfig->getExpirationTime(), + ); + + ksort($discoveredEntities); + + $this->entityCollectionStore->store($trustAnchorId, $discoveredEntities, $ttl); + $lastUpdated = time(); + $this->entityCollectionStore->storeLastUpdated($trustAnchorId, $lastUpdated, $ttl); + + $this->logger?->info('Federation discovery completed.', [ + 'trustAnchorId' => $trustAnchorId, + 'discoveredCount' => count($discoveredEntities), + ]); + } catch (Throwable $throwable) { + $this->logger?->error('Federation discovery failed.', [ + 'trustAnchorId' => $trustAnchorId, + 'error' => $throwable->getMessage(), + ]); + } + + return $this->entityCollectionFactory->build($discoveredEntities, $lastUpdated); + } + + + /** + * Fetch an entity collection from a remote endpoint. + * + * @param non-empty-string $endpointUri + * @param array{ + * entity_type?: string[], + * trust_mark_type?: string[], + * query?: string, + * trust_anchor?: string, + * entity_claims?: string[], + * ui_claims?: string[], + * limit?: positive-int, + * from?: string, + * } $filters + * @throws \SimpleSAML\OpenID\Exceptions\EntityDiscoveryException + */ + public function fetchFromCollectionEndpoint( + string $endpointUri, + array $filters = [], + bool $forceRefresh = false, + ): EntityCollection { + $uri = $this->helpers->url()->withMultiValueParams($endpointUri, $filters); + + if (!$forceRefresh) { + $this->logger?->debug('Checking for cached entity collection.', ['uri' => $uri]); + $cached = $this->artifactFetcher->fromCacheAsString($uri); + if ($cached !== null) { + $this->logger?->debug('Returning cached entity collection.', ['uri' => $uri]); + return $this->buildEntityCollectionFromResponse($cached); + } + + $this->logger?->debug('No cached entity collection found.', ['uri' => $uri]); + } + + $this->logger?->debug('Fetching entity collection.', ['uri' => $uri, 'filters' => $filters]); + + try { + $responseBody = $this->artifactFetcher->fromNetworkAsString($uri); + + $collection = $this->buildEntityCollectionFromResponse($responseBody); + + $this->artifactFetcher->cacheIt( + $responseBody, + $this->maxCacheDurationDecorator->getInSeconds(), + $uri, + ); + + $this->logger?->debug('Fetched and cached entity collection.', ['uri' => $uri]); + + return $collection; + } catch (Throwable $throwable) { + $message = sprintf('Unable to fetch entity collection from %s. Error: %s', $uri, $throwable->getMessage()); + $this->logger?->error($message); + throw new EntityDiscoveryException($message, (int)$throwable->getCode(), $throwable); + } + } + + + protected function buildEntityCollectionFromResponse(string $responseBody): EntityCollection + { + $decoded = $this->helpers->json()->decode($responseBody); + + if ( + !is_array($decoded) || + !isset($decoded[ClaimsEnum::Entities->value]) || + !is_array($decoded[ClaimsEnum::Entities->value]) + ) { + throw new EntityDiscoveryException('Entity collection response is missing "entities" array.'); + } + + $entities = []; + foreach ($decoded[ClaimsEnum::Entities->value] as $entryData) { + if (!is_array($entryData)) { + continue; + } + + $entityId = $this->helpers->type()->ensureNonEmptyString( + $entryData[ClaimsEnum::EntityId->value] ?? null, + ClaimsEnum::EntityId->value, + ); + + $metadata = []; + $uiInfos = $entryData[ClaimsEnum::UiInfos->value] ?? []; + if (is_array($uiInfos)) { + foreach ($uiInfos as $type => $typePayload) { + if (is_string($type) && is_array($typePayload)) { + $metadata[$type] = $typePayload; + } + } + } + + $payload = [ + ClaimsEnum::Sub->value => $entityId, + ClaimsEnum::Metadata->value => $metadata, + ]; + + if (isset($entryData[ClaimsEnum::TrustMarks->value])) { + $payload[ClaimsEnum::TrustMarks->value] = $entryData[ClaimsEnum::TrustMarks->value]; + } + + $entities[$entityId] = $payload; + } + + $next = is_string($next = $decoded[ClaimsEnum::Next->value] ?? null) ? $next : null; + $lastUpdated = is_numeric($lastUpdated = $decoded[ClaimsEnum::LastUpdated->value] ?? null) ? + $this->helpers->type()->ensureInt($lastUpdated) : + null; + + return $this->entityCollectionFactory->build( + $entities, + $lastUpdated, + $next, + ); + } + + + /** + * Discover just the entity IDs in the federation. + * + * @param non-empty-string $trustAnchorId + * @param array $filters + * @return string[] + */ + public function discoverEntityIds( + string $trustAnchorId, + array $filters = [], + bool $forceRefresh = false, + ): array { + return array_keys($this->discover($trustAnchorId, $filters, $forceRefresh)->getEntities()); + } + + + /** + * @param non-empty-string $entityId + * @param array $filters + * @param string[] $visited + * @return array> + */ + protected function traverse( + string $entityId, + EntityStatement $entityConfig, + array $filters, + int $depth = 0, + array $visited = [], + bool $forceRefresh = false, + ): array { + if ($depth > $this->maxDepth || in_array($entityId, $visited, true)) { + return []; + } + + $visited[] = $entityId; + $allCollectedEntities = [$entityId => $entityConfig->getPayload()]; + + $listEndpoint = $entityConfig->getFederationListEndpoint(); + if (is_null($listEndpoint)) { + return $allCollectedEntities; + } + + try { + $subordinateIds = $this->subordinateListingFetcher->fetch($listEndpoint, $filters, $forceRefresh); + + foreach ($subordinateIds as $subId) { + // If we've already visited this subId (loop), skip to avoid infinite recursion + if (in_array($subId, $visited, true)) { + continue; + } + + try { + $subConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($subId); + $allCollectedEntities = array_merge( + $allCollectedEntities, + $this->traverse($subId, $subConfig, $filters, $depth + 1, $visited, $forceRefresh), + ); + } catch (Throwable $e) { + $this->logger?->warning('Failed to fetch subordinate configuration during discovery.', [ + 'entityId' => $entityId, + 'subId' => $subId, + 'error' => $e->getMessage(), + ]); + // Still include the ID if we discovered it from the list, but with an empty payload + if (!isset($allCollectedEntities[$subId])) { + $allCollectedEntities[$subId] = []; + } + } + } + } catch (Throwable $throwable) { + $this->logger?->error('Failed to fetch subordinate listing during discovery.', [ + 'entityId' => $entityId, + 'error' => $throwable->getMessage(), + ]); + } + + return $allCollectedEntities; + } +} diff --git a/src/Federation/SubordinateListingFetcher.php b/src/Federation/SubordinateListingFetcher.php new file mode 100644 index 0000000..472f2c2 --- /dev/null +++ b/src/Federation/SubordinateListingFetcher.php @@ -0,0 +1,91 @@ + $filters Optional query params: entity_type, intermediate, etc. + * @param bool $forceRefresh If true, ignore cached listing and fetch from network. + * @return non-empty-string[] + * @throws \SimpleSAML\OpenID\Exceptions\FetchException + * @throws \SimpleSAML\OpenID\Exceptions\EntityDiscoveryException + */ + public function fetch(string $listEndpointUri, array $filters = [], bool $forceRefresh = false): array + { + $uri = $this->helpers->url()->withMultiValueParams($listEndpointUri, $filters); + + if (!$forceRefresh) { + $this->logger?->debug('Checking for cached subordinate listing.', ['uri' => $uri]); + $cached = $this->artifactFetcher->fromCacheAsString($uri); + if (is_string($cached)) { + $this->logger?->debug('Returning cached subordinate listing.', ['uri' => $uri]); + return $this->decodeAndEnsureType($cached); + } + + $this->logger?->debug('No cached subordinate listing found.', ['uri' => $uri]); + } + + $this->logger?->debug('Fetching subordinate listing from network.', ['uri' => $uri, 'filters' => $filters]); + + try { + $responseBody = $this->artifactFetcher->fromNetworkAsString($uri); + $this->logger?->debug('Fetched subordinate listing from network.', ['uri' => $uri]); + + $result = $this->decodeAndEnsureType($responseBody); + + $this->artifactFetcher->cacheIt( + $responseBody, + $this->maxCacheDurationDecorator->getInSeconds(), + $uri, + ); + + return $result; + } catch (Throwable $throwable) { + $message = sprintf( + 'Unable to fetch subordinate listing from %s. Error: %s', + $uri, + $throwable->getMessage(), + ); + $this->logger?->error($message); + throw new EntityDiscoveryException($message, (int)$throwable->getCode(), $throwable); + } + } + + + /** + * @return non-empty-string[] + * @throws \SimpleSAML\OpenID\Exceptions\EntityDiscoveryException + */ + protected function decodeAndEnsureType(string $responseBody): array + { + $decoded = $this->helpers->json()->decode($responseBody); + + if (!is_array($decoded)) { + throw new EntityDiscoveryException('Subordinate listing response is not a JSON array.'); + } + + return $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings($decoded, 'Subordinate Listing'); + } +} diff --git a/src/Helpers/Arr.php b/src/Helpers/Arr.php index cd14b32..42f3a91 100644 --- a/src/Helpers/Arr.php +++ b/src/Helpers/Arr.php @@ -135,9 +135,7 @@ public function addNestedValue(array &$array, mixed $value, int|string ...$keys) */ public function getNestedValue(array $array, int|string ...$keys): mixed { - if (count($keys) > 99) { - throw new OpenIdException('Refusing to recurse to given depth.'); - } + $this->validateMaxDepth(count($keys)); if (count($keys) < 1) { return null; @@ -162,6 +160,10 @@ public function getNestedValue(array $array, int|string ...$keys): mixed */ public function isAssociative(array $array): bool { + if ($array === []) { + return false; + } + // Has at least one string key or non-sequential numeric keys return array_keys($array) !== range(0, count($array) - 1); } diff --git a/src/Helpers/Url.php b/src/Helpers/Url.php index 0f6e8e5..48d4fa8 100644 --- a/src/Helpers/Url.php +++ b/src/Helpers/Url.php @@ -33,6 +33,66 @@ public function withParams(string $url, array $params): string $queryParams = array_merge($queryParams, $params); $newQueryString = http_build_query($queryParams); + return $this->prepareUri($parsedUri, $newQueryString); + } + + + /** + * Build a URL with repeated (multi-value) query parameters. + * Array values are serialized as repeated keys: ?key=a&key=b + * + * @param array|string|int|float> $params + */ + public function withMultiValueParams(string $url, array $params): string + { + if ($params === []) { + return $url; + } + + $parsedUri = parse_url($url); + + $queryParams = []; + if (isset($parsedUri['query'])) { + parse_str($parsedUri['query'], $queryParams); + } + + $queryElements = []; + // Preserve existing query params + foreach ($queryParams as $key => $value) { + $strKey = (string)$key; + if (is_array($value)) { + foreach ($value as $subValue) { + /** @var string $subValue */ + $queryElements[] = urlencode($strKey) . '=' . urlencode($subValue); + } + } else { + /** @var string $value */ + $queryElements[] = urlencode($strKey) . '=' . urlencode($value); + } + } + + // Add new multi-value params + foreach ($params as $key => $value) { + if (is_array($value)) { + foreach ($value as $subValue) { + $queryElements[] = urlencode($key) . '=' . urlencode((string)$subValue); + } + } else { + $queryElements[] = urlencode($key) . '=' . urlencode((string)$value); + } + } + + $newQueryString = implode('&', $queryElements); + + return $this->prepareUri($parsedUri, $newQueryString); + } + + + /** + * @param array $parsedUri + */ + protected function prepareUri(false|array|int|string|null $parsedUri, string $newQueryString): string + { return (isset($parsedUri['scheme']) ? $parsedUri['scheme'] . '://' : '') . ($parsedUri['host'] ?? '') . (isset($parsedUri['port']) ? ':' . $parsedUri['port'] : '') . diff --git a/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactory.php b/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactory.php index fd24bf9..54163e5 100644 --- a/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactory.php +++ b/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactory.php @@ -16,20 +16,6 @@ class VcSdJwtFactory extends SdJwtFactory { - public function fromToken(string $token): VcSdJwt - { - return new VcSdJwt( - $this->jwsDecoratorBuilder->fromToken($token), - $this->jwsVerifierDecorator, - $this->jwksDecoratorFactory, - $this->jwsSerializerManagerDecorator, - $this->timestampValidationLeeway, - $this->helpers, - $this->claimFactory, - ); - } - - /** * @param array $payload * @param array $header diff --git a/tests/src/Core/LogoutTokenTest.php b/tests/src/Core/LogoutTokenTest.php new file mode 100644 index 0000000..6881cb5 --- /dev/null +++ b/tests/src/Core/LogoutTokenTest.php @@ -0,0 +1,282 @@ +createMock(JWS::class); + $jwsMock->method('getPayload') + ->willReturn('json-payload-string'); + + $this->jwsDecoratorMock = $this->createMock(JwsDecorator::class); + $this->jwsDecoratorMock->method('jws')->willReturn($jwsMock); + + $this->jwsVerifierDecoratorMock = $this->createStub(JwsVerifierDecorator::class); + $this->jwksDecoratorFactoryMock = $this->createStub(JwksDecoratorFactory::class); + $this->jwsSerializerManagerDecoratorMock = $this->createStub(JwsSerializerManagerDecorator::class); + $this->dateIntervalDecoratorMock = $this->createStub(DateIntervalDecorator::class); + + $this->helpersMock = $this->createMock(Helpers::class); + $this->jsonHelperMock = $this->createMock(Helpers\Json::class); + $this->helpersMock->method('json')->willReturn($this->jsonHelperMock); + $typeHelperMock = $this->createMock(Helpers\Type::class); + $this->helpersMock->method('type')->willReturn($typeHelperMock); + + $typeHelperMock->method('ensureNonEmptyString')->willReturnArgument(0); + $typeHelperMock->method('ensureInt')->willReturnArgument(0); + $typeHelperMock->method('enforceUri')->willReturnArgument(0); + $typeHelperMock->method('ensureArrayWithValuesAsStrings')->willReturnArgument(0); + + $this->claimFactoryMock = $this->createStub(ClaimFactory::class); + + $this->validPayload = [ + 'iss' => 'https://server.example.com', + 'sub' => '24400320', + 'aud' => 's6BhdRkqt3', + 'iat' => time(), + 'exp' => time() + 3600, + 'jti' => 'bWJq', + 'events' => [ + 'http://schemas.openid.net/event/backchannel-logout' => (object) [], + ], + ]; + } + + + protected function sut( + ?JwsDecorator $jwsDecorator = null, + ?JwsVerifierDecorator $jwsVerifierDecorator = null, + ?JwksDecoratorFactory $jwksDecoratorFactory = null, + ?JwsSerializerManagerDecorator $jwsSerializerManagerDecorator = null, + ?DateIntervalDecorator $dateIntervalDecorator = null, + ?Helpers $helpers = null, + ?ClaimFactory $claimFactory = null, + ): LogoutToken { + $jwsDecorator ??= $this->jwsDecoratorMock; + $jwsVerifierDecorator ??= $this->jwsVerifierDecoratorMock; + $jwksDecoratorFactory ??= $this->jwksDecoratorFactoryMock; + $jwsSerializerManagerDecorator ??= $this->jwsSerializerManagerDecoratorMock; + $dateIntervalDecorator ??= $this->dateIntervalDecoratorMock; + $helpers ??= $this->helpersMock; + $claimFactory ??= $this->claimFactoryMock; + + return new LogoutToken( + $jwsDecorator, + $jwsVerifierDecorator, + $jwksDecoratorFactory, + $jwsSerializerManagerDecorator, + $dateIntervalDecorator, + $helpers, + $claimFactory, + ); + } + + + public function testCanCreateInstance(): void + { + $this->jsonHelperMock->method('decode')->willReturn($this->validPayload); + $this->assertInstanceOf(LogoutToken::class, $this->sut()); + } + + + public function testCanGetRequiredClaims(): void + { + $this->jsonHelperMock->method('decode')->willReturn($this->validPayload); + $sut = $this->sut(); + + $this->assertSame($this->validPayload['iss'], $sut->getIssuer()); + $this->assertSame([$this->validPayload['aud']], $sut->getAudience()); + $this->assertSame($this->validPayload['iat'], $sut->getIssuedAt()); + $this->assertSame($this->validPayload['exp'], $sut->getExpirationTime()); + $this->assertSame($this->validPayload['jti'], $sut->getJwtId()); + $this->assertSame($this->validPayload['events'], $sut->getEvents()); + } + + + public function testGetIssuerThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['iss']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No Issuer claim found.'); + + $this->sut(); + } + + + public function testGetAudienceThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['aud']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No Audience claim found.'); + + $this->sut(); + } + + + public function testGetIssuedAtThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['iat']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No Issued At claim found.'); + + $this->sut(); + } + + + public function testGetExpirationTimeThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['exp']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No Expiration Time claim found.'); + + $this->sut(); + } + + + public function testGetJwtIdThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['jti']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No JWT ID claim found.'); + + $this->sut(); + } + + + public function testGetEventsThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['events']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No Events claim found.'); + + $this->sut(); + } + + + public function testGetEventsThrowsWhenMalformed(): void + { + $payload = $this->validPayload; + $payload['events'] = ['wrong-event' => []]; + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Malformed events claim.'); + + $this->sut(); + } + + + public function testCanGetSessionId(): void + { + $payload = $this->validPayload; + $payload['sid'] = 'session-id'; + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->assertSame('session-id', $this->sut()->getSessionId()); + } + + + public function testGetNonceThrowsWhenPresent(): void + { + $payload = $this->validPayload; + $payload['nonce'] = 'some-nonce'; + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Nonce claim is forbidden in Logout Token.'); + + $this->sut(); + } + + + public function testThrowsWhenBothSubAndSidAreMissing(): void + { + $payload = $this->validPayload; + unset($payload['sub']); + unset($payload['sid']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Missing Subject and Session ID claim in Logout Token.'); + + $this->sut(); + } + + + public function testDoesNotThrowWhenSubIsMissingButSidIsPresent(): void + { + $payload = $this->validPayload; + unset($payload['sub']); + $payload['sid'] = 'session-id'; + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->assertInstanceOf(LogoutToken::class, $this->sut()); + } + + + public function testDoesNotThrowWhenSidIsMissingButSubIsPresent(): void + { + $payload = $this->validPayload; + unset($payload['sid']); + // sub is already in validPayload + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->assertInstanceOf(LogoutToken::class, $this->sut()); + } +} diff --git a/tests/src/Federation/EntityCollection/CacheEntityCollectionStoreTest.php b/tests/src/Federation/EntityCollection/CacheEntityCollectionStoreTest.php new file mode 100644 index 0000000..a6e132c --- /dev/null +++ b/tests/src/Federation/EntityCollection/CacheEntityCollectionStoreTest.php @@ -0,0 +1,167 @@ +cacheDecorator = $this->createMock(CacheDecorator::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->store = new CacheEntityCollectionStore( + $this->cacheDecorator, + $this->createStub(Helpers::class), + $this->logger, + ); + } + + + public function testStore(): void + { + $entities = ['id1' => ['sub' => 'id1']]; + $this->cacheDecorator->expects($this->once()) + ->method('set') + ->with($entities, 3600, 'federation_entities', 'anchor'); + + $this->store->store('anchor', $entities, 3600); + } + + + public function testStoreFailureLogsError(): void + { + $this->cacheDecorator->method('set')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->store->store('anchor', [], 3600); + } + + + public function testGet(): void + { + $entities = ['id1' => ['sub' => 'id1']]; + $this->cacheDecorator->expects($this->once()) + ->method('get') + ->with(null, 'federation_entities', 'anchor') + ->willReturn($entities); + + $this->assertSame($entities, $this->store->get('anchor')); + } + + + public function testGetReturnsNullIfNotArray(): void + { + $this->cacheDecorator->method('get')->willReturn('not-an-array'); + $this->assertNull($this->store->get('anchor')); + } + + + public function testGetFailureLogsError(): void + { + $this->cacheDecorator->method('get')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->assertNull($this->store->get('anchor')); + } + + + public function testClear(): void + { + $this->cacheDecorator->expects($this->once()) + ->method('delete') + ->with('federation_entities', 'anchor'); + + $this->store->clear('anchor'); + } + + + public function testClearFailureLogsError(): void + { + $this->cacheDecorator->method('delete')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->store->clear('anchor'); + } + + + public function testStoreLastUpdated(): void + { + $this->cacheDecorator->expects($this->once()) + ->method('set') + ->with('123456789', 3600, 'last_updated', 'anchor'); + + $this->store->storeLastUpdated('anchor', 123456789, 3600); + } + + + public function testGetLastUpdated(): void + { + $this->cacheDecorator->expects($this->once()) + ->method('get') + ->with(null, 'last_updated', 'anchor') + ->willReturn(123456789); + + $this->assertSame(123456789, $this->store->getLastUpdated('anchor')); + } + + + public function testGetLastUpdatedReturnsNullIfNotInt(): void + { + $this->cacheDecorator->method('get')->willReturn('string'); + $this->assertNull($this->store->getLastUpdated('anchor')); + } + + + public function testClearLastUpdated(): void + { + $this->cacheDecorator->expects($this->once()) + ->method('delete') + ->with('last_updated', 'anchor'); + + $this->store->clearLastUpdated('anchor'); + } + + + public function testStoreLastUpdatedFailureLogsError(): void + { + $this->cacheDecorator->method('set')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->store->storeLastUpdated('anchor', 123456789, 3600); + } + + + public function testClearLastUpdatedFailureLogsError(): void + { + $this->cacheDecorator->method('delete')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->store->clearLastUpdated('anchor'); + } + + + public function testGetLastUpdatedFailureLogsError(): void + { + $this->cacheDecorator->method('get')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->assertNull($this->store->getLastUpdated('anchor')); + } +} diff --git a/tests/src/Federation/EntityCollection/EntityCollectionFilterTest.php b/tests/src/Federation/EntityCollection/EntityCollectionFilterTest.php new file mode 100644 index 0000000..eefe772 --- /dev/null +++ b/tests/src/Federation/EntityCollection/EntityCollectionFilterTest.php @@ -0,0 +1,188 @@ +filter = new EntityCollectionFilter($this->createStub(Helpers::class)); + } + + + public function testFilterByEntityType(): void + { + $entities = [ + 'idp' => [ + ClaimsEnum::Metadata->value => [ + 'openid_provider' => [], + ], + ], + 'rp' => [ + ClaimsEnum::Metadata->value => [ + 'openid_relying_party' => [], + ], + ], + 'both' => [ + ClaimsEnum::Metadata->value => [ + 'openid_provider' => [], + 'openid_relying_party' => [], + ], + ], + 'none' => [ + ClaimsEnum::Metadata->value => [], + ], + 'invalid' => [ + ClaimsEnum::Metadata->value => 'string', + ], + ]; + + // Filter by openid_provider + $result = $this->filter->filter($entities, ['entity_type' => ['openid_provider']]); + $this->assertCount(2, $result); + $this->assertArrayHasKey('idp', $result); + $this->assertArrayHasKey('both', $result); + + // Filter by openid_relying_party + $result = $this->filter->filter($entities, ['entity_type' => ['openid_relying_party']]); + $this->assertCount(2, $result); + $this->assertArrayHasKey('rp', $result); + $this->assertArrayHasKey('both', $result); + + // Filter by both + $result = $this->filter->filter($entities, ['entity_type' => ['openid_provider', 'openid_relying_party']]); + $this->assertCount(3, $result); + $this->assertArrayHasKey('idp', $result); + $this->assertArrayHasKey('rp', $result); + $this->assertArrayHasKey('both', $result); + } + + + public function testFilterByTrustMarkType(): void + { + $entities = [ + 'm1' => [ + ClaimsEnum::TrustMarks->value => [ + [ClaimsEnum::TrustMarkType->value => 'type1'], + ], + ], + 'm12' => [ + ClaimsEnum::TrustMarks->value => [ + [ClaimsEnum::TrustMarkType->value => 'type1'], + [ClaimsEnum::TrustMarkType->value => 'type2'], + ], + ], + 'm2' => [ + ClaimsEnum::TrustMarks->value => [ + [ClaimsEnum::TrustMarkType->value => 'type2'], + ], + ], + 'none' => [], + 'invalid' => [ + ClaimsEnum::TrustMarks->value => 'string', + ], + ]; + + // Filter by type1 + $result = $this->filter->filter($entities, ['trust_mark_type' => ['type1']]); + $this->assertCount(2, $result); + $this->assertArrayHasKey('m1', $result); + $this->assertArrayHasKey('m12', $result); + + // Filter by type1 AND type2 + $result = $this->filter->filter($entities, ['trust_mark_type' => ['type1', 'type2']]); + $this->assertCount(1, $result); + $this->assertArrayHasKey('m12', $result); + } + + + public function testFilterByQuery(): void + { + $entities = [ + 'idp' => [ + ClaimsEnum::Sub->value => 'https://idp.example.com', + ClaimsEnum::Metadata->value => [ + 'openid_provider' => [ + ClaimsEnum::DisplayName->value => 'Example IdP', + ClaimsEnum::OrganizationName->value => 'Example Org', + ], + ], + ], + 'rp' => [ + ClaimsEnum::Sub->value => 'https://rp.example.com', + ClaimsEnum::Metadata->value => [ + 'openid_relying_party' => [ + ClaimsEnum::DisplayName->value => 'Example RP', + ], + ], + ], + 'other' => [ + ClaimsEnum::Sub->value => 'https://other.example.com', + ClaimsEnum::Metadata->value => [ + 'federation_entity' => [ + ClaimsEnum::OrganizationName->value => 'Other Org', + ], + ], + ], + ]; + + // Query by sub + $result = $this->filter->filter($entities, ['query' => 'idp']); + $this->assertCount(1, $result); + $this->assertArrayHasKey('idp', $result); + + // Query by display_name + $result = $this->filter->filter($entities, ['query' => 'IdP']); + $this->assertCount(1, $result); + $this->assertArrayHasKey('idp', $result); + + // Query by organization_name + $result = $this->filter->filter($entities, ['query' => 'Other']); + $this->assertCount(1, $result); + $this->assertArrayHasKey('other', $result); + + // Query with no results + $result = $this->filter->filter($entities, ['query' => 'nomatch']); + $this->assertCount(0, $result); + } + + + public function testFilterWithInvalidMetadataStructures(): void + { + $entities = [ + 'invalid_metadata' => [ + ClaimsEnum::Metadata->value => 'not-an-array', + ], + 'invalid_trustmarks' => [ + ClaimsEnum::TrustMarks->value => 'not-an-array', + ], + 'invalid_type_payload' => [ + ClaimsEnum::Metadata->value => [ + 'openid_provider' => 'not-an-array', + ], + ], + ]; + + // invalid_metadata and invalid_trustmarks are excluded (return false on line 50) + // invalid_type_payload is INCLUDED because isset($metadata['openid_provider']) is true + $this->assertCount(1, $this->filter->filter($entities, ['entity_type' => ['openid_provider']])); + + // all are excluded for trust_mark_type + $this->assertCount(0, $this->filter->filter($entities, ['trust_mark_type' => ['type1']])); + + // all are excluded for query + $this->assertCount(0, $this->filter->filter($entities, ['query' => 'something'])); + } +} diff --git a/tests/src/Federation/EntityCollection/EntityCollectionPaginatorTest.php b/tests/src/Federation/EntityCollection/EntityCollectionPaginatorTest.php new file mode 100644 index 0000000..eda5413 --- /dev/null +++ b/tests/src/Federation/EntityCollection/EntityCollectionPaginatorTest.php @@ -0,0 +1,105 @@ +createMock(Helpers::class); + $this->base64Url = $this->createMock(Base64Url::class); + $helpers->method('base64Url')->willReturn($this->base64Url); + $this->paginator = new EntityCollectionPaginator($helpers); + } + + + public function testPaginateFirstPage(): void + { + $entities = [ + 'id1' => ['sub' => 'id1'], + 'id2' => ['sub' => 'id2'], + 'id3' => ['sub' => 'id3'], + ]; + + $this->base64Url->expects($this->once()) + ->method('encode') + ->with('id2') + ->willReturn('YmFzZTY0LWlkMg'); + + $result = $this->paginator->paginate($entities, 2); + + $expected = [ + ClaimsEnum::Entities->value => [ + 'id1' => ['sub' => 'id1'], + 'id2' => ['sub' => 'id2'], + ], + ClaimsEnum::Next->value => 'YmFzZTY0LWlkMg', + ]; + + $this->assertSame($expected, $result); + } + + + public function testPaginateSecondPage(): void + { + $entities = [ + 'id1' => ['sub' => 'id1'], + 'id2' => ['sub' => 'id2'], + 'id3' => ['sub' => 'id3'], + ]; + + $this->base64Url->expects($this->once()) + ->method('decode') + ->with('YmFzZTY0LWlkMQ') + ->willReturn('id1'); + + // No more pages after this one (limit 2, offset 1 means id2, id3 are returned) + $result = $this->paginator->paginate($entities, 2, 'YmFzZTY0LWlkMQ'); + + $expected = [ + ClaimsEnum::Entities->value => [ + 'id2' => ['sub' => 'id2'], + 'id3' => ['sub' => 'id3'], + ], + ClaimsEnum::Next->value => null, + ]; + + $this->assertSame($expected, $result); + } + + + public function testPaginateInvalidCursor(): void + { + $entities = [ + 'id1' => ['sub' => 'id1'], + 'id2' => ['sub' => 'id2'], + ]; + + $this->base64Url->expects($this->once()) + ->method('decode') + ->with('invalid') + ->willReturn('non-existent'); + + // If cursor is not found, it starts from the beginning (offset 0) + $result = $this->paginator->paginate($entities, 1, 'invalid'); + + $this->assertArrayHasKey('id1', $result[ClaimsEnum::Entities->value]); + $this->assertCount(1, $result[ClaimsEnum::Entities->value]); + } +} diff --git a/tests/src/Federation/EntityCollection/EntityCollectionSorterTest.php b/tests/src/Federation/EntityCollection/EntityCollectionSorterTest.php new file mode 100644 index 0000000..371131e --- /dev/null +++ b/tests/src/Federation/EntityCollection/EntityCollectionSorterTest.php @@ -0,0 +1,153 @@ +createMock(Helpers::class); + $this->arr = $this->createMock(Arr::class); + $helpers->method('arr')->willReturn($this->arr); + $this->sorter = new EntityCollectionSorter($helpers); + } + + + public function testSortAscending(): void + { + $entities = [ + 'z' => ['val' => 'Zebra'], + 'a' => ['val' => 'Apple'], + 'm' => ['val' => 'Monkey'], + ]; + + $claimPaths = [['val']]; + + $this->arr->method('getNestedValue') + ->willReturnCallback(fn(array $arr, string $path): mixed => $arr[$path]); + + $result = $this->sorter->sort($entities, $claimPaths, 'asc'); + + $this->assertSame(['a', 'm', 'z'], array_keys($result)); + } + + + public function testSortDescending(): void + { + $entities = [ + 'z' => ['val' => 'Zebra'], + 'a' => ['val' => 'Apple'], + 'm' => ['val' => 'Monkey'], + ]; + + $claimPaths = [['val']]; + + $this->arr->method('getNestedValue') + ->willReturnCallback(fn(array $arr, string $path): mixed => $arr[$path]); + + $result = $this->sorter->sort($entities, $claimPaths, 'desc'); + + $this->assertSame(['z', 'm', 'a'], array_keys($result)); + } + + + public function testSortMissingClaim(): void + { + $entities = [ + 'a' => ['val' => 'Apple'], + 'b' => [], // Missing 'val' + ]; + + $claimPaths = [['val']]; + + $this->arr->method('getNestedValue') + ->willReturnCallback(function (array $arr, string $path) { + if (!isset($arr[$path])) { + throw new OpenIdException('Missing'); + } + + return $arr[$path]; + }); + + $result = $this->sorter->sort($entities, $claimPaths, 'asc'); + + // 'b' (null/empty string) should come before 'a' ('Apple') + $this->assertSame(['b', 'a'], array_keys($result)); + } + + + public function testSortMultiplePaths(): void + { + $entities = [ + 'id1' => ['v1' => 'A', 'v2' => 'B'], + 'id2' => ['v1' => 'A', 'v2' => 'A'], + ]; + + $claimPaths = [['v1'], ['v2']]; + + $this->arr->method('getNestedValue') + ->willReturnCallback(fn(array $arr, string $path): mixed => $arr[$path]); + + $result = $this->sorter->sort($entities, $claimPaths, 'asc'); + + $this->assertSame(['id2', 'id1'], array_keys($result)); + } + + + public function testSortEmptyEntities(): void + { + $this->assertSame([], $this->sorter->sort([], [['any']])); + } + + + public function testSortEqualValues(): void + { + $entities = [ + 'id1' => ['val' => 'A'], + 'id2' => ['val' => 'A'], + ]; + + $claimPaths = [['val']]; + + $this->arr->method('getNestedValue')->willReturn('A'); + + $result = $this->sorter->sort($entities, $claimPaths, 'asc'); + + // Order should be preserved if equal + $this->assertSame(['id1', 'id2'], array_keys($result)); + } + + + public function testSortDescendingDifferentValues(): void + { + $entities = [ + 'id1' => ['val' => 'A'], + 'id2' => ['val' => 'B'], + ]; + + $claimPaths = [['val']]; + + $this->arr->method('getNestedValue') + ->willReturnCallback(fn(array $arr): mixed => $arr['val']); + + $result = $this->sorter->sort($entities, $claimPaths, 'desc'); + + $this->assertSame(['id2', 'id1'], array_keys($result)); + } +} diff --git a/tests/src/Federation/EntityCollection/InMemoryEntityCollectionStoreTest.php b/tests/src/Federation/EntityCollection/InMemoryEntityCollectionStoreTest.php new file mode 100644 index 0000000..673b426 --- /dev/null +++ b/tests/src/Federation/EntityCollection/InMemoryEntityCollectionStoreTest.php @@ -0,0 +1,68 @@ +store = new InMemoryEntityCollectionStore(); + } + + + public function testStoreAndGet(): void + { + $entities = ['id1' => ['sub' => 'id1']]; + $this->store->store('anchor', $entities, 3600); + + $this->assertSame($entities, $this->store->get('anchor')); + } + + + public function testGetExpired(): void + { + $entities = ['id1' => ['sub' => 'id1']]; + // Store with negative TTL so it's immediately expired + $this->store->store('anchor', $entities, -10); + + $this->assertNull($this->store->get('anchor')); + } + + + public function testGetNonExistent(): void + { + $this->assertNull($this->store->get('non-existent')); + } + + + public function testClear(): void + { + $entities = ['id1' => ['sub' => 'id1']]; + $this->store->store('anchor', $entities, 3600); + $this->store->clear('anchor'); + + $this->assertNull($this->store->get('anchor')); + } + + + public function testLastUpdated(): void + { + $timestamp = 123456789; + $this->store->storeLastUpdated('anchor', $timestamp, 3600); + + $this->assertSame($timestamp, $this->store->getLastUpdated('anchor')); + + $this->store->clearLastUpdated('anchor'); + $this->assertNull($this->store->getLastUpdated('anchor')); + } +} diff --git a/tests/src/Federation/EntityCollectionTest.php b/tests/src/Federation/EntityCollectionTest.php new file mode 100644 index 0000000..4e2cdfc --- /dev/null +++ b/tests/src/Federation/EntityCollectionTest.php @@ -0,0 +1,243 @@ +filter = $this->createMock(EntityCollectionFilter::class); + $this->sorter = $this->createMock(EntityCollectionSorter::class); + $this->paginator = $this->createMock(EntityCollectionPaginator::class); + } + + + public function testGetEntities(): void + { + $entities = ['https://idp.example.com' => ['sub' => 'https://idp.example.com']]; + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + $entities, + ); + + $this->assertSame($entities, $collection->getEntities()); + } + + + public function testGetLastUpdated(): void + { + $lastUpdated = 123456789; + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + [], + null, + $lastUpdated, + ); + + $this->assertSame($lastUpdated, $collection->getLastUpdated()); + } + + + public function testGetNextPageToken(): void + { + $token = 'opaque-token'; + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + [], + $token, + ); + + $this->assertSame($token, $collection->getNextPageToken()); + } + + + public function testToCollectionEndpointResponseArray(): void + { + $entities = [ + 'https://idp.example.com' => [ + ClaimsEnum::Sub->value => 'https://idp.example.com', + ClaimsEnum::Metadata->value => [ + 'openid_provider' => [ + 'issuer' => 'https://idp.example.com', + ], + ], + ClaimsEnum::TrustMarks->value => [ + ['id' => 'mark1'], + ], + ], + 'https://rp.example.com' => [ + ClaimsEnum::Sub->value => 'https://rp.example.com', + // No metadata + ], + 'https://broken.example.com' => [ + ClaimsEnum::Sub->value => 'https://broken.example.com', + ClaimsEnum::Metadata->value => 'invalid-metadata', // Not an array + ], + ]; + + $lastUpdated = 1620000000; + $nextToken = 'next-page'; + + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + $entities, + $nextToken, + $lastUpdated, + ); + + $expected = [ + ClaimsEnum::Entities->value => [ + [ + ClaimsEnum::EntityId->value => 'https://idp.example.com', + ClaimsEnum::EntityTypes->value => ['openid_provider'], + ClaimsEnum::UiInfos->value => [ + 'openid_provider' => [ + 'issuer' => 'https://idp.example.com', + ], + ], + ClaimsEnum::TrustMarks->value => [ + ['id' => 'mark1'], + ], + ], + [ + ClaimsEnum::EntityId->value => 'https://rp.example.com', + ClaimsEnum::EntityTypes->value => [], + ], + [ + ClaimsEnum::EntityId->value => 'https://broken.example.com', + ClaimsEnum::EntityTypes->value => [], + ], + ], + ClaimsEnum::Next->value => $nextToken, + ClaimsEnum::LastUpdated->value => $lastUpdated, + ]; + + $this->assertSame($expected, $collection->toCollectionEndpointResponseArray()); + } + + + public function testToCollectionEndpointResponseArrayWithNulls(): void + { + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + [], + ); + + $expected = [ + ClaimsEnum::Entities->value => [], + ]; + + $this->assertSame($expected, $collection->toCollectionEndpointResponseArray()); + } + + + public function testFilter(): void + { + $entities = ['a' => []]; + $criteria = ['entity_type' => ['openid_provider']]; + $filtered = ['b' => []]; + + $this->filter->expects($this->once()) + ->method('filter') + ->with($entities, $criteria) + ->willReturn($filtered); + + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + $entities, + ); + + $result = $collection->filter($criteria); + + $this->assertSame($collection, $result); + $this->assertSame($filtered, $collection->getEntities()); + } + + + public function testSort(): void + { + $entities = ['a' => []]; + $claimPaths = [['metadata', 'openid_provider', 'organization_name']]; + $sortOrder = 'asc'; + $sorted = ['b' => []]; + + $this->sorter->expects($this->once()) + ->method('sort') + ->with($entities, $claimPaths, $sortOrder) + ->willReturn($sorted); + + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + $entities, + ); + + $result = $collection->sort($claimPaths, $sortOrder); + + $this->assertSame($collection, $result); + $this->assertSame($sorted, $collection->getEntities()); + } + + + public function testPaginate(): void + { + $entities = ['a' => [], 'b' => []]; + $limit = 1; + $from = 'cursor'; + $paginatedEntities = ['b' => []]; + $nextToken = 'next-cursor'; + + $this->paginator->expects($this->once()) + ->method('paginate') + ->with($entities, $limit, $from) + ->willReturn([ + 'entities' => $paginatedEntities, + 'next' => $nextToken, + ]); + + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + $entities, + ); + + $result = $collection->paginate($limit, $from); + + $this->assertSame($collection, $result); + $this->assertSame($paginatedEntities, $collection->getEntities()); + $this->assertSame($nextToken, $collection->getNextPageToken()); + } +} diff --git a/tests/src/Federation/FederationDiscoveryTest.php b/tests/src/Federation/FederationDiscoveryTest.php new file mode 100644 index 0000000..d522119 --- /dev/null +++ b/tests/src/Federation/FederationDiscoveryTest.php @@ -0,0 +1,522 @@ +entityStatementFetcherMock = $this->createMock(EntityStatementFetcher::class); + $this->subordinateListingFetcherMock = $this->createMock(SubordinateListingFetcher::class); + $this->entityCollectionStoreMock = $this->createMock(EntityCollectionStoreInterface::class); + $this->maxCacheDurationDecoratorMock = $this->createMock(DateIntervalDecorator::class); + $this->entityCollectionFactoryMock = $this->createMock(EntityCollectionFactory::class); + $this->artifactFetcherMock = $this->createMock(ArtifactFetcher::class); + $this->helpersMock = $this->createMock(Helpers::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + } + + + protected function sut(int $maxDepth = 10): FederationDiscovery + { + return new FederationDiscovery( + $this->entityStatementFetcherMock, + $this->subordinateListingFetcherMock, + $this->entityCollectionStoreMock, + $this->maxCacheDurationDecoratorMock, + $this->entityCollectionFactoryMock, + $this->artifactFetcherMock, + $this->helpersMock, + $this->loggerMock, + $maxDepth, + ); + } + + + public function testDiscoverReturnsCachedEntities(): void + { + $trustAnchorId = 'https://ta.example.org'; + $cachedEntities = ['https://entity.example.org' => ['sub' => 'https://entity.example.org']]; + $lastUpdated = 1234567890; + $collection = $this->createStub(EntityCollection::class); + + $this->entityCollectionStoreMock->expects($this->once()) + ->method('get') + ->with($trustAnchorId) + ->willReturn($cachedEntities); + + $this->entityCollectionStoreMock->expects($this->once()) + ->method('getLastUpdated') + ->with($trustAnchorId) + ->willReturn($lastUpdated); + + $this->entityCollectionFactoryMock->expects($this->once()) + ->method('build') + ->with($cachedEntities, $lastUpdated) + ->willReturn($collection); + + $result = $this->sut()->discover($trustAnchorId); + $this->assertSame($collection, $result); + } + + + public function testDiscoverBypassesCacheOnForceRefresh(): void + { + $trustAnchorId = 'https://ta.example.org'; + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $trustAnchorId]); + $taConfig->method('getFederationListEndpoint')->willReturn(null); + + $this->entityCollectionStoreMock->expects($this->never()) + ->method('get'); + + $this->entityStatementFetcherMock->expects($this->once()) + ->method('fromCacheOrWellKnownEndpoint') + ->with($trustAnchorId) + ->willReturn($taConfig); + + $this->maxCacheDurationDecoratorMock->method('lowestInSecondsComparedToExpirationTime') + ->willReturn(3600); + + $collection = $this->createStub(EntityCollection::class); + $this->entityCollectionFactoryMock->method('build')->willReturn($collection); + + $result = $this->sut()->discover($trustAnchorId, [], true); + $this->assertSame($collection, $result); + } + + + public function testDiscoverWithTraversal(): void + { + $taId = 'https://ta.example.org'; + $subId = 'https://sub.example.org'; + $leafId = 'https://leaf.example.org'; + + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $taId]); + $taConfig->method('getFederationListEndpoint')->willReturn('https://ta.example.org/list'); + + $subConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $subConfig->method('getPayload')->willReturn(['sub' => $subId]); + $subConfig->method('getFederationListEndpoint')->willReturn('https://sub.example.org/list'); + + $leafConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $leafConfig->method('getPayload')->willReturn(['sub' => $leafId]); + $leafConfig->method('getFederationListEndpoint')->willReturn(null); + + $this->entityStatementFetcherMock->expects($this->exactly(3))->method('fromCacheOrWellKnownEndpoint') + ->willReturnMap([ + [$taId, $taConfig], + [$subId, $subConfig], + [$leafId, $leafConfig], + ]); + + $this->subordinateListingFetcherMock->expects($this->exactly(2))->method('fetch') + ->willReturnMap([ + ['https://ta.example.org/list', [], false, [$subId]], + ['https://sub.example.org/list', [], false, [$leafId]], + ]); + + $this->maxCacheDurationDecoratorMock->method('lowestInSecondsComparedToExpirationTime') + ->willReturn(3600); + + $expectedEntities = [ + $leafId => ['sub' => $leafId], + $subId => ['sub' => $subId], + $taId => ['sub' => $taId], + ]; + + $this->entityCollectionStoreMock->expects($this->once()) + ->method('store') + ->with($taId, $expectedEntities, 3600); + + $this->sut()->discover($taId); + } + + + public function testDiscoverHandlesTraversalError(): void + { + $taId = 'https://ta.example.org'; + $subId = 'https://sub.example.org'; + + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $taId]); + $taConfig->method('getFederationListEndpoint')->willReturn('https://ta.example.org/list'); + + $this->entityStatementFetcherMock->method('fromCacheOrWellKnownEndpoint') + ->willReturnCallback(function ( + string $id, + ) use ( + $taId, + $taConfig, +): \PHPUnit\Framework\MockObject\MockObject { + if ($id === $taId) { + return $taConfig; + } + + throw new \Exception('Fetch failed'); + }); + + $this->subordinateListingFetcherMock->method('fetch') + ->with('https://ta.example.org/list') + ->willReturn([$subId]); + + $this->maxCacheDurationDecoratorMock->method('lowestInSecondsComparedToExpirationTime') + ->willReturn(3600); + + $expectedEntities = [ + $subId => [], // Should include subId with empty payload on failure + $taId => ['sub' => $taId], + ]; + + $this->entityCollectionStoreMock->expects($this->once()) + ->method('store') + ->with($taId, $expectedEntities, 3600); + + $this->sut()->discover($taId); + } + + + public function testFetchFromCollectionEndpointSuccess(): void + { + $endpointUri = 'https://example.org/collection'; + $filters = ['entity_type' => ['openid_provider']]; + $fullUri = 'https://example.org/collection?entity_type=openid_provider'; + $responseBody = json_encode([ + 'entities' => [ + ['entity_id' => 'https://op1.example.org', 'ui_infos' => ['openid_provider' => ['name' => 'OP1']]], + ], + 'next' => 'https://example.org/collection?from=op2', + 'last_updated' => 1234567890, + ]); + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->with($endpointUri, $filters)->willReturn($fullUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $jsonHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $jsonHelper->method('decode')->willReturn(json_decode($responseBody, true)); + $this->helpersMock->method('json')->willReturn($jsonHelper); + + $typeHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Type::class); + $typeHelper->method('ensureNonEmptyString')->willReturn('https://op1.example.org'); + $typeHelper->method('ensureInt')->willReturn(1234567890); + $this->helpersMock->method('type')->willReturn($typeHelper); + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromCacheAsString') + ->with($fullUri) + ->willReturn(null); + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromNetworkAsString') + ->with($fullUri) + ->willReturn($responseBody); + + $this->maxCacheDurationDecoratorMock->method('getInSeconds')->willReturn(3600); + + $this->artifactFetcherMock->expects($this->once()) + ->method('cacheIt') + ->with($responseBody, 3600, $fullUri); + + $collection = $this->createStub(EntityCollection::class); + $this->entityCollectionFactoryMock->expects($this->once()) + ->method('build') + ->with( + ['https://op1.example.org' => [ + 'sub' => 'https://op1.example.org', + 'metadata' => ['openid_provider' => ['name' => 'OP1']], + ]], + 1234567890, + 'https://example.org/collection?from=op2', + ) + ->willReturn($collection); + + $result = $this->sut()->fetchFromCollectionEndpoint($endpointUri, $filters); + $this->assertSame($collection, $result); + } + + + public function testFetchFromCollectionEndpointFailure(): void + { + $endpointUri = 'https://example.org/collection'; + $fullUri = 'https://example.org/collection'; + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn($fullUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $this->artifactFetcherMock->method('fromNetworkAsString') + ->willThrowException(new \Exception('Network error')); + + $this->expectException(EntityDiscoveryException::class); + $this->expectExceptionMessage('Unable to fetch entity collection'); + + $this->sut()->fetchFromCollectionEndpoint($endpointUri); + } + + + public function testDiscoverEntityIds(): void + { + $trustAnchorId = 'https://ta.example.org'; + $collection = $this->createMock(EntityCollection::class); + $collection->method('getEntities')->willReturn([ + 'https://e1.example.org' => [], + 'https://e2.example.org' => [], + ]); + + $this->entityCollectionStoreMock->method('get')->willReturn(['some' => 'data']); + $this->entityCollectionFactoryMock->method('build')->willReturn($collection); + + $result = $this->sut()->discoverEntityIds($trustAnchorId); + $this->assertSame(['https://e1.example.org', 'https://e2.example.org'], $result); + } + + + public function testTraverseRespectsMaxDepth(): void + { + $taId = 'https://ta.example.org'; + $subId = 'https://sub.example.org'; + + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $taId]); + $taConfig->method('getFederationListEndpoint')->willReturn('https://ta.example.org/list'); + + $this->entityStatementFetcherMock->method('fromCacheOrWellKnownEndpoint')->willReturn($taConfig); + $this->subordinateListingFetcherMock->method('fetch')->willReturn([$subId]); + + // sut with maxDepth = 0 + $this->entityCollectionStoreMock->expects($this->once()) + ->method('store') + ->with($taId, [$taId => ['sub' => $taId]], $this->anything()); + + $this->sut(0)->discover($taId); + } + + + public function testTraverseAvoidsLoops(): void + { + $taId = 'https://ta.example.org'; + + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $taId]); + $taConfig->method('getFederationListEndpoint')->willReturn('https://ta.example.org/list'); + + $this->entityStatementFetcherMock->method('fromCacheOrWellKnownEndpoint')->willReturn($taConfig); + // List endpoint returns the TA ID itself (a loop) + $this->subordinateListingFetcherMock->method('fetch')->willReturn([$taId]); + + $this->entityCollectionStoreMock->expects($this->once()) + ->method('store') + ->with($taId, [$taId => ['sub' => $taId]], $this->anything()); + + $this->sut()->discover($taId); + } + + + public function testDiscoverLogsErrorOnException(): void + { + $trustAnchorId = 'https://ta.example.org'; + $this->entityStatementFetcherMock->method('fromCacheOrWellKnownEndpoint') + ->willThrowException(new \Exception('Critical failure')); + + $this->loggerMock->expects($this->once()) + ->method('error') + ->with('Federation discovery failed.', $this->anything()); + + $this->sut()->discover($trustAnchorId); + } + + + public function testFetchFromCollectionEndpointReturnsCached(): void + { + $endpointUri = 'https://example.org/collection'; + $responseBody = json_encode(['entities' => []]); + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn($endpointUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromCacheAsString') + ->with($endpointUri) + ->willReturn($responseBody); + + $jsonHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $jsonHelper->method('decode')->willReturn(['entities' => []]); + $this->helpersMock->method('json')->willReturn($jsonHelper); + + $collection = $this->createStub(EntityCollection::class); + $this->entityCollectionFactoryMock->method('build')->willReturn($collection); + + $result = $this->sut()->fetchFromCollectionEndpoint($endpointUri); + $this->assertSame($collection, $result); + } + + + public function testBuildEntityCollectionFromResponseThrowsOnMissingEntities(): void + { + $endpointUri = 'https://example.org/collection'; + $responseBody = json_encode(['invalid' => 'data']); + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn($endpointUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $this->artifactFetcherMock->method('fromNetworkAsString')->willReturn($responseBody); + + $jsonHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $jsonHelper->method('decode')->willReturn(['invalid' => 'data']); + $this->helpersMock->method('json')->willReturn($jsonHelper); + + $this->expectException(EntityDiscoveryException::class); + $this->expectExceptionMessage('missing "entities" array'); + + $this->sut()->fetchFromCollectionEndpoint($endpointUri); + } + + + public function testBuildEntityCollectionFromResponseSkipsNonArrayEntries(): void + { + $endpointUri = 'https://example.org/collection'; + $responseBody = json_encode([ + 'entities' => [ + 'not-an-array', + ['entity_id' => 'https://valid.example.org'], + ], + ]); + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn($endpointUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $this->artifactFetcherMock->method('fromNetworkAsString')->willReturn($responseBody); + + $jsonHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $jsonHelper->method('decode')->willReturn([ + 'entities' => [ + 'not-an-array', + ['entity_id' => 'https://valid.example.org'], + ], + ]); + $this->helpersMock->method('json')->willReturn($jsonHelper); + + $typeHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Type::class); + $typeHelper->method('ensureNonEmptyString')->willReturn('https://valid.example.org'); + $this->helpersMock->method('type')->willReturn($typeHelper); + + $this->entityCollectionFactoryMock->expects($this->once()) + ->method('build') + ->with(['https://valid.example.org' => [ + 'sub' => 'https://valid.example.org', + 'metadata' => [], + ]]) + ->willReturn($this->createStub(EntityCollection::class)); + + $this->sut()->fetchFromCollectionEndpoint($endpointUri); + } + + + public function testBuildEntityCollectionFromResponseWithTrustMarksAndLastUpdated(): void + { + $endpointUri = 'https://example.org/collection'; + $responseBody = json_encode([ + 'entities' => [ + [ + 'entity_id' => 'https://valid.example.org', + 'trust_marks' => [['id' => 'tm1']], + ], + ], + 'last_updated' => '1234567890', + ]); + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn($endpointUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $this->artifactFetcherMock->method('fromNetworkAsString')->willReturn($responseBody); + + $jsonHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $jsonHelper->method('decode')->willReturn(json_decode($responseBody, true)); + $this->helpersMock->method('json')->willReturn($jsonHelper); + + $typeHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Type::class); + $typeHelper->method('ensureNonEmptyString')->willReturn('https://valid.example.org'); + $typeHelper->method('ensureInt')->willReturn(1234567890); + $this->helpersMock->method('type')->willReturn($typeHelper); + + $this->entityCollectionFactoryMock->expects($this->once()) + ->method('build') + ->with( + ['https://valid.example.org' => [ + 'sub' => 'https://valid.example.org', + 'metadata' => [], + 'trust_marks' => [['id' => 'tm1']], + ]], + 1234567890, + ) + ->willReturn($this->createStub(EntityCollection::class)); + + $this->sut()->fetchFromCollectionEndpoint($endpointUri); + } + + + public function testTraverseHandlesSubordinateListingFailure(): void + { + $taId = 'https://ta.example.org'; + + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $taId]); + $taConfig->method('getFederationListEndpoint')->willReturn('https://ta.example.org/list'); + + $this->entityStatementFetcherMock->method('fromCacheOrWellKnownEndpoint')->willReturn($taConfig); + $this->subordinateListingFetcherMock->method('fetch')->willThrowException(new \Exception('Listing failed')); + + $this->loggerMock->expects($this->once()) + ->method('error') + ->with('Failed to fetch subordinate listing during discovery.', $this->anything()); + + $this->sut()->discover($taId); + } +} diff --git a/tests/src/Federation/SubordinateListingFetcherTest.php b/tests/src/Federation/SubordinateListingFetcherTest.php new file mode 100644 index 0000000..1cb2e48 --- /dev/null +++ b/tests/src/Federation/SubordinateListingFetcherTest.php @@ -0,0 +1,161 @@ +artifactFetcherMock = $this->createMock(ArtifactFetcher::class); + $this->helpersMock = $this->createMock(Helpers::class); + $this->jsonHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $this->typeHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\Type::class); + $this->maxCacheDurationMock = $this->createMock(DateIntervalDecorator::class); + + // Set up common helper mocks + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn('http://example.com/list'); + $this->helpersMock->method('url')->willReturn($urlHelper); + $this->helpersMock->method('json')->willReturn($this->jsonHelperMock); + $this->helpersMock->method('type')->willReturn($this->typeHelperMock); + } + + + protected function sut(): SubordinateListingFetcher + { + return new SubordinateListingFetcher( + $this->artifactFetcherMock, + $this->helpersMock, + $this->maxCacheDurationMock, + $this->createStub(\Psr\Log\LoggerInterface::class), + ); + } + + + public function testFetchReturnsCachedDataIfAvailable(): void + { + $uri = 'http://example.com/list'; + $cachedResponse = '["sub1", "sub2"]'; + $decodedResponse = ['sub1', 'sub2']; + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromCacheAsString') + ->with($uri) + ->willReturn($cachedResponse); + + $this->artifactFetcherMock->expects($this->never()) + ->method('fromNetworkAsString'); + + $this->jsonHelperMock->expects($this->once()) + ->method('decode') + ->with($cachedResponse) + ->willReturn($decodedResponse); + + $this->typeHelperMock->expects($this->once()) + ->method('ensureArrayWithValuesAsNonEmptyStrings') + ->with($decodedResponse) + ->willReturn($decodedResponse); + + $result = $this->sut()->fetch($uri); + $this->assertSame($decodedResponse, $result); + } + + + public function testFetchFromNetworkOnCacheMissAndCachesResult(): void + { + $uri = 'http://example.com/list'; + $networkResponse = '["sub3"]'; + $decodedResponse = ['sub3']; + $ttl = 3600; + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromCacheAsString') + ->with($uri) + ->willReturn(null); + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromNetworkAsString') + ->with($uri) + ->willReturn($networkResponse); + + $this->jsonHelperMock->expects($this->once()) + ->method('decode') + ->with($networkResponse) + ->willReturn($decodedResponse); + + $this->typeHelperMock->expects($this->once()) + ->method('ensureArrayWithValuesAsNonEmptyStrings') + ->with($decodedResponse) + ->willReturn($decodedResponse); + + $this->maxCacheDurationMock->method('getInSeconds')->willReturn($ttl); + + $this->artifactFetcherMock->expects($this->once()) + ->method('cacheIt') + ->with($networkResponse, $ttl, $uri); + + $result = $this->sut()->fetch($uri); + $this->assertSame($decodedResponse, $result); + } + + + public function testForceRefreshBypassesCache(): void + { + $uri = 'http://example.com/list'; + $networkResponse = '["sub4"]'; + $decodedResponse = ['sub4']; + + $this->artifactFetcherMock->expects($this->never()) + ->method('fromCacheAsString'); + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromNetworkAsString') + ->with($uri) + ->willReturn($networkResponse); + + $this->jsonHelperMock->method('decode')->willReturn($decodedResponse); + $this->typeHelperMock->method('ensureArrayWithValuesAsNonEmptyStrings')->willReturn($decodedResponse); + + $result = $this->sut()->fetch($uri, [], true); + $this->assertSame($decodedResponse, $result); + } + + + public function testFetchThrowsExceptionOnInvalidJson(): void + { + $uri = 'http://example.com/list'; + $invalidJson = 'invalid'; + + $this->artifactFetcherMock->method('fromCacheAsString')->willReturn(null); + $this->artifactFetcherMock->method('fromNetworkAsString')->willReturn($invalidJson); + $this->jsonHelperMock->method('decode')->willReturn(null); // Invalid JSON decodes to null + + $this->expectException(EntityDiscoveryException::class); + $this->expectExceptionMessage('JSON array'); + + $this->sut()->fetch($uri); + } +} diff --git a/tests/src/FederationTest.php b/tests/src/FederationTest.php index ecbfd8f..2949865 100644 --- a/tests/src/FederationTest.php +++ b/tests/src/FederationTest.php @@ -22,14 +22,22 @@ use SimpleSAML\OpenID\Factories\HttpClientDecoratorFactory; use SimpleSAML\OpenID\Factories\JwsSerializerManagerDecoratorFactory; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Federation\EntityCollection\CacheEntityCollectionStore; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionFilter; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionPaginator; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionSorter; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface; use SimpleSAML\OpenID\Federation\EntityStatementFetcher; +use SimpleSAML\OpenID\Federation\Factories\EntityCollectionFactory; use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory; use SimpleSAML\OpenID\Federation\Factories\RequestObjectFactory; use SimpleSAML\OpenID\Federation\Factories\TrustChainFactory; use SimpleSAML\OpenID\Federation\Factories\TrustMarkDelegationFactory; use SimpleSAML\OpenID\Federation\Factories\TrustMarkFactory; +use SimpleSAML\OpenID\Federation\FederationDiscovery; use SimpleSAML\OpenID\Federation\MetadataPolicyApplicator; use SimpleSAML\OpenID\Federation\MetadataPolicyResolver; +use SimpleSAML\OpenID\Federation\SubordinateListingFetcher; use SimpleSAML\OpenID\Federation\TrustChainResolver; use SimpleSAML\OpenID\Federation\TrustMarkFetcher; use SimpleSAML\OpenID\Federation\TrustMarkStatusResponseFetcher; @@ -80,6 +88,13 @@ #[UsesClass(TrustMarkFetcher::class)] #[UsesClass(TrustMarkStatusResponseFetcher::class)] #[UsesClass(KeyPairResolver::class)] +#[UsesClass(CacheEntityCollectionStore::class)] +#[UsesClass(EntityCollectionFilter::class)] +#[UsesClass(EntityCollectionPaginator::class)] +#[UsesClass(EntityCollectionSorter::class)] +#[UsesClass(EntityCollectionFactory::class)] +#[UsesClass(FederationDiscovery::class)] +#[UsesClass(SubordinateListingFetcher::class)] final class FederationTest extends TestCase { protected \PHPUnit\Framework\MockObject\Stub $supportedAlgorithmsMock; @@ -169,5 +184,12 @@ public function testCanBuildTools(): void $this->assertInstanceOf(TrustMarkValidator::class, $sut->trustMarkValidator()); $this->assertInstanceOf(TrustMarkFetcher::class, $sut->trustMarkFetcher()); $this->assertInstanceOf(KeyPairResolver::class, $sut->keyPairResolver()); + $this->assertInstanceOf(SubordinateListingFetcher::class, $sut->subordinateListingFetcher()); + $this->assertInstanceOf(EntityCollectionStoreInterface::class, $sut->entityCollectionStore()); + $this->assertInstanceOf(EntityCollectionFactory::class, $sut->entityCollectionFactory()); + $this->assertInstanceOf(FederationDiscovery::class, $sut->federationDiscovery()); + $this->assertInstanceOf(EntityCollectionFilter::class, $sut->entityCollectionFilter()); + $this->assertInstanceOf(EntityCollectionSorter::class, $sut->entityCollectionSorter()); + $this->assertInstanceOf(EntityCollectionPaginator::class, $sut->entityCollectionPaginator()); } } diff --git a/tests/src/Helpers/ArrTest.php b/tests/src/Helpers/ArrTest.php index 9d29882..06b188a 100644 --- a/tests/src/Helpers/ArrTest.php +++ b/tests/src/Helpers/ArrTest.php @@ -161,4 +161,71 @@ public function testAddNestedValueThrowsForNonArrayPathElements(): void $arr = ['a' => 'b']; $this->sut()->addNestedValue($arr, 'c', 'a'); } + + + public function testIsAssociative(): void + { + $this->assertFalse($this->sut()->isAssociative([])); + $this->assertFalse($this->sut()->isAssociative(['a', 'b', 'c'])); + $this->assertTrue($this->sut()->isAssociative(['a' => 'b'])); + $this->assertTrue($this->sut()->isAssociative([1 => 'b'])); // Not sequential from 0 + $this->assertTrue($this->sut()->isAssociative([0 => 'a', 2 => 'b'])); + } + + + public function testIsOfArrays(): void + { + $this->assertTrue($this->sut()->isOfArrays([])); + $this->assertTrue($this->sut()->isOfArrays([[], [1]])); + $this->assertFalse($this->sut()->isOfArrays([[], 'a'])); + } + + + public function testContainsKey(): void + { + $arr = ['a' => ['b' => ['c' => 'd']], 'e' => 'f']; + $this->assertTrue($this->sut()->containsKey($arr, 'a')); + $this->assertTrue($this->sut()->containsKey($arr, 'b')); + $this->assertTrue($this->sut()->containsKey($arr, 'c')); + $this->assertTrue($this->sut()->containsKey($arr, 'e')); + $this->assertFalse($this->sut()->containsKey($arr, 'd')); + $this->assertFalse($this->sut()->containsKey($arr, 'f')); + $this->assertFalse($this->sut()->containsKey($arr, 'g')); + } + + + public function testHybridSort(): void + { + // Numeric keys + $arr = [3, 1, 2]; + $this->sut()->hybridSort($arr); + $this->assertSame([1, 2, 3], $arr); + + // String keys + $arr = ['b' => 2, 'a' => 1, 'c' => 3]; + $this->sut()->hybridSort($arr); + $this->assertSame(['a' => 1, 'b' => 2, 'c' => 3], $arr); + + // Nested + $arr = [ + 'b' => [3, 1, 2], + 'a' => ['y' => 2, 'x' => 1], + ]; + $this->sut()->hybridSort($arr); + $this->assertSame([ + 'a' => ['x' => 1, 'y' => 2], + 'b' => [1, 2, 3], + ], $arr); + } + + + public function testValidateMaxDepth(): void + { + $this->sut()->validateMaxDepth(Arr::MAX_DEPTH); + $this->assertTrue(true); + + $this->expectException(OpenIdException::class); + $this->expectExceptionMessage('Refusing to recurse'); + $this->sut()->validateMaxDepth(Arr::MAX_DEPTH + 1); + } } diff --git a/tests/src/Helpers/UrlTest.php b/tests/src/Helpers/UrlTest.php index cc02f35..a842d75 100644 --- a/tests/src/Helpers/UrlTest.php +++ b/tests/src/Helpers/UrlTest.php @@ -43,4 +43,31 @@ public function testCanAddParams(): void $this->sut()->withParams($url, ['c' => 'd']), ); } + + + public function testCanAddMultiValueParams(): void + { + $url = 'https://example.com/'; + + $this->assertSame( + 'https://example.com/', + $this->sut()->withMultiValueParams($url, []), + ); + + $this->assertSame( + 'https://example.com/?a=b&a=c', + $this->sut()->withMultiValueParams($url, ['a' => ['b', 'c']]), + ); + + $this->assertSame( + 'https://example.com/?a=b&c=d', + $this->sut()->withMultiValueParams($url, ['a' => 'b', 'c' => 'd']), + ); + + $url = 'https://example.com/?x=y'; + $this->assertSame( + 'https://example.com/?x=y&a=b&a=c', + $this->sut()->withMultiValueParams($url, ['a' => ['b', 'c']]), + ); + } } diff --git a/tests/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactoryTest.php b/tests/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactoryTest.php index 2f2c9b5..056eaf4 100644 --- a/tests/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactoryTest.php +++ b/tests/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactoryTest.php @@ -102,23 +102,6 @@ protected function createJwsDecoratorMock(array $payload = []): MockObject } - public function testCanBuildFromToken(): void - { - $jwsDecoratorMock = $this->createJwsDecoratorMock(); - - $this->jwsDecoratorBuilderMock - ->expects($this->once()) - ->method('fromToken') - ->with('token') - ->willReturn($jwsDecoratorMock); - - $this->assertInstanceOf( - VcSdJwt::class, - $this->sut()->fromToken('token'), - ); - } - - public function testCanBuildFromData(): void { $signingKey = $this->createStub(JwkDecorator::class);