From 65553d664eb070913fb416473ee7d2027899656d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 4 Jul 2026 16:32:39 +0200 Subject: [PATCH 01/10] Initial RP initiated logout implementation --- README.md | 6 +- docs/2-Pre-Registered-Client.md | 59 ++++ docs/3-Federated-Client.md | 35 +++ docs/4-Dynamically-Registered-Client.md | 35 ++- src/DataStore/DataHandlers/StateNonce.php | 10 +- src/DynamicallyRegisteredClient.php | 90 ++++++ src/FederatedClient.php | 116 +++++++- src/PreRegisteredClient.php | 144 ++++++++- src/Protocol/RequestDataHandler.php | 195 +++++++++++- .../DataStore/DataHandlers/StateNonceTest.php | 26 ++ .../Oidc/DynamicallyRegisteredClientTest.php | 99 +++++++ tests/Oidc/FederatedClientTest.php | 134 +++++++++ tests/Oidc/PreRegisteredClientTest.php | 183 +++++++++++- .../Oidc/Protocol/RequestDataHandlerTest.php | 280 ++++++++++++++++++ 14 files changed, 1398 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 3bbe877..eb118ce 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Generic OpenID Connect (OIDC) client (RP) written in PHP. It uses OIDC Authorization Code Flow to perform authentication. It implements JWKS public key usage and automatic key rollover, caching mechanism (file based by default), ID token verification and claims extraction, -'userinfo' user data fetching and has support for automatic client -registration for federated environments, as well as OpenID Connect Dynamic -Client Registration. +'userinfo' user data fetching, OIDC RP-Initiated Logout, and has support for +automatic client registration for federated environments, as well as OpenID +Connect Dynamic Client Registration. For information on how to use this client, refer to the [documentation](docs/1-Index.md). diff --git a/docs/2-Pre-Registered-Client.md b/docs/2-Pre-Registered-Client.md index 7d85506..b9ece4c 100644 --- a/docs/2-Pre-Registered-Client.md +++ b/docs/2-Pre-Registered-Client.md @@ -206,6 +206,65 @@ claims that have multiple values, for example: ), ``` +## RP-Initiated Logout + +If the OpenID Provider advertises an `end_session_endpoint` in its metadata, +you can use the `logout()` method to perform +[OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). + +After a successful login (`getUserData()`), the client persists the raw ID +token and related login data in the session store. On `logout()`, the client +removes that login data (local logout) and delivers a logout request to the +OP's end session endpoint, carrying the ID token as `id_token_hint`, the +`client_id`, and a `state` parameter (if state check is enabled). Note that +destroying the application session itself (for example, `session_destroy()`) +remains the application's responsibility. + +```php +use Cicnavi\Oidc\PreRegisteredClient; +/** @var PreRegisteredClient $oidcClient */ + +// File: logout.php +try { + // Destroy your own application session as appropriate, then: + $oidcClient->logout( + // Optional. Must be registered on the OP as one of the client's + // 'post_logout_redirect_uris': + postLogoutRedirectUri: 'https://client.example.org/logged-out.php', + ); +} catch (\Throwable $exception) { + // In a real app log the error, redirect the user and show an error message. + throw $exception; +} +``` + +If a `post_logout_redirect_uri` was provided, the OP will redirect the user +back to it after logout, returning the `state` parameter. Validate it using +`validateLogoutCallback()`: + +```php +use Cicnavi\Oidc\PreRegisteredClient; +/** @var PreRegisteredClient $oidcClient */ + +// File: logged-out.php +try { + $oidcClient->validateLogoutCallback(); + // Show a "logged out" page... +} catch (\Throwable $exception) { + // In a real app log the error and show an error message. + throw $exception; +} +``` + +The `logout()` method also accepts optional `logoutHint` and `uiLocales` +parameters, a `logoutRequestMethod` (HTTP GET redirect by default), and a +PSR-7 `response` instance which will be populated with proper headers and +returned (instead of performing an immediate redirect). + +The raw ID token received at login is also available using the +`getIdToken()` method (and related login data using `getLoginData()`), for +example, if you need to build a custom logout request yourself. + ## Note on Caching OIDC client uses caching to avoid sending HTTP requests to fetch OIDC diff --git a/docs/3-Federated-Client.md b/docs/3-Federated-Client.md index 58a2113..408b63a 100644 --- a/docs/3-Federated-Client.md +++ b/docs/3-Federated-Client.md @@ -141,6 +141,41 @@ public function callback(ServerRequestInterface $request) { See the [LoginController Example](../examples/FederatedClient/FederationLoginController.php) for a sample implementation. +### 4. RP-Initiated Logout + +If the OP advertised an `end_session_endpoint` in its (resolved) metadata at +login time, you can use the `logout()` method to perform +[OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). +Since the OP is resolved per authorization flow, the end session endpoint is +snapshotted at login time (during `getUserData()`) together with the raw ID +token, and used later when `logout()` is called. + +The client removes the persisted login data (local logout) and delivers a +logout request to the OP's end session endpoint, carrying the ID token as +`id_token_hint`, the RP entity ID as `client_id`, and a `state` parameter. +Note that destroying the application session itself remains the +application's responsibility. + +```php +/** @var \Cicnavi\Oidc\FederatedClient $client */ + +// Destroy your own application session as appropriate, then: +$client->logout( + // Optional. Must be registered as one of this RP's + // 'post_logout_redirect_uris' metadata values (which can be provided + // using the Relying Party configuration additional claims): + postLogoutRedirectUri: 'https://rp.example.org/logged-out', +); +``` + +If a `post_logout_redirect_uri` was provided, validate the redirected +request using `validateLogoutCallback()` (verifies the returned `state`): + +```php +/** @var \Cicnavi\Oidc\FederatedClient $client */ +$client->validateLogoutCallback(); +``` + ## Entity Configuration Endpoint To participate in a federation, your RP must publish its diff --git a/docs/4-Dynamically-Registered-Client.md b/docs/4-Dynamically-Registered-Client.md index 26e3894..8a775c2 100644 --- a/docs/4-Dynamically-Registered-Client.md +++ b/docs/4-Dynamically-Registered-Client.md @@ -104,7 +104,10 @@ prepared from the constructor parameters: * `scope` - the provided scope, * `client_name` - if provided using the `clientName` parameter, * `software_id` - by default (can be disabled using the `includeSoftwareId` -parameter). +parameter), +* `post_logout_redirect_uris` - if provided using the +`postLogoutRedirectUris` parameter (used for RP-Initiated Logout, see +below). Any additional client metadata claims can be provided using the `additionalClientMetadata` parameter. Claims provided here override the @@ -172,6 +175,36 @@ $oidcClient->authorize(); $userData = $oidcClient->getUserData(); ``` +### RP-Initiated Logout + +RP-Initiated Logout is also available, same as for the +[Pre-Registered Client](2-Pre-Registered-Client.md): use `logout()` to +deliver a logout request to the OP's end session endpoint, and +`validateLogoutCallback()` on the post logout redirect URI. To use a +`post_logout_redirect_uri` in the logout request, register it first using +the `postLogoutRedirectUris` constructor parameter: + +```php +use Cicnavi\Oidc\DynamicallyRegisteredClient; + +$oidcClient = new DynamicallyRegisteredClient( + opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', + redirectUri: 'https://your-example.org/callback', + scope: 'openid profile', + postLogoutRedirectUris: ['https://your-example.org/logged-out'], +); + +// File: logout.php +$oidcClient->logout(postLogoutRedirectUri: 'https://your-example.org/logged-out'); + +// File: logged-out.php +$oidcClient->validateLogoutCallback(); +``` + +Note that providing `postLogoutRedirectUris` changes the client metadata +set, so an existing client registration will be updated (or replaced) +accordingly. + ## Requirements on the OpenID Provider * OP metadata must advertise a `registration_endpoint`. diff --git a/src/DataStore/DataHandlers/StateNonce.php b/src/DataStore/DataHandlers/StateNonce.php index 7030967..7268698 100644 --- a/src/DataStore/DataHandlers/StateNonce.php +++ b/src/DataStore/DataHandlers/StateNonce.php @@ -23,12 +23,20 @@ class StateNonce extends AbstractDataHandler implements StateNonceDataHandlerInt */ public const NONCE_KEY = 'OIDC_NONCE_PARAMETER'; + /** + * @var string State key used for session storage of the RP-Initiated + * Logout state parameter. Separate from STATE_KEY so a logout request + * does not clobber an in-flight authorization request state. + */ + public const LOGOUT_STATE_KEY = 'OIDC_LOGOUT_STATE_PARAMETER'; + /** * @var string[] */ protected static array $validParameterKeys = [ self::STATE_KEY, - self::NONCE_KEY + self::NONCE_KEY, + self::LOGOUT_STATE_KEY, ]; /** diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php index 1413aeb..9c3e6bf 100644 --- a/src/DynamicallyRegisteredClient.php +++ b/src/DynamicallyRegisteredClient.php @@ -133,6 +133,11 @@ class DynamicallyRegisteredClient * instance to delegate protocol operations to. If not provided (default), * one will be built after registration using the issued client * credentials. Intended primarily for testing. + * @param string[] $postLogoutRedirectUris URIs to register as + * 'post_logout_redirect_uris' during client registration, so they can be + * used as the post logout redirect URI in RP-Initiated Logout (see + * logout()). Note that providing this changes the client metadata set, + * so an existing registration will be updated (or replaced) accordingly. * * For other parameters, refer to PreRegisteredClient - they are forwarded * to the underlying client instance which is built after registration. @@ -184,6 +189,7 @@ public function __construct( protected readonly ParModeEnum $parMode = ParModeEnum::Auto, ?ClientRegistrationHandler $registrationHandler = null, protected ?PreRegisteredClient $preRegisteredClient = null, + protected readonly array $postLogoutRedirectUris = [], ) { $this->cache = $cache ?? new FileCache( 'odrcpc-' . md5($this->opConfigurationUrl . '|' . $this->redirectUri), @@ -245,6 +251,20 @@ protected function validateClientMetadata(array $clientMetadata): void ResponseTypesEnum::Code->value, ); $this->validateClientMetadataScope($clientMetadata); + + foreach ($this->postLogoutRedirectUris as $postLogoutRedirectUri) { + if ($postLogoutRedirectUri === '') { + throw new OidcClientException( + 'Post logout redirect URIs must be non-empty strings.', + ); + } + + $this->validateClientMetadataContains( + $clientMetadata, + ClaimsEnum::PostLogoutRedirectUris->value, + $postLogoutRedirectUri, + ); + } } /** @@ -617,6 +637,70 @@ public function getUserData(?ServerRequestInterface $request = null): array } } + /** + * Perform RP-Initiated Logout using the underlying pre-registered client + * instance built from the current client registration. + * + * @param ?string $postLogoutRedirectUri URI to which the OP should + * redirect the user agent after logout. Must be one of the + * 'post_logout_redirect_uris' registered on the OP (see the + * $postLogoutRedirectUris constructor parameter). + * @see PreRegisteredClient::logout() + * @throws OidcClientException + */ + public function logout( + ?string $postLogoutRedirectUri = null, + ?string $logoutHint = null, + ?string $uiLocales = null, + AuthorizationRequestMethodEnum $logoutRequestMethod = AuthorizationRequestMethodEnum::Query, + ?ResponseInterface $response = null, + ): ?ResponseInterface { + return $this->resolvePreRegisteredClient()->logout( + $postLogoutRedirectUri, + $logoutHint, + $uiLocales, + $logoutRequestMethod, + $response, + ); + } + + /** + * Validate the request made to the post logout redirect URI after an + * RP-Initiated Logout. + * + * @see PreRegisteredClient::validateLogoutCallback() + * @throws OidcClientException + */ + public function validateLogoutCallback(?ServerRequestInterface $request = null): void + { + $this->resolvePreRegisteredClient()->validateLogoutCallback($request); + } + + /** + * Raw ID token received at the last successful login, or null when not + * available. + * + * @see PreRegisteredClient::getIdToken() + * @throws OidcClientException + */ + public function getIdToken(): ?string + { + return $this->resolvePreRegisteredClient()->getIdToken(); + } + + /** + * Login data persisted at the last successful login, or null when not + * available. + * + * @return mixed[]|null + * @see PreRegisteredClient::getLoginData() + * @throws OidcClientException + */ + public function getLoginData(): ?array + { + return $this->resolvePreRegisteredClient()->getLoginData(); + } + /** * @return MetadataInterface OIDC Configuration URL content (OIDC metadata). */ @@ -653,6 +737,12 @@ public function buildClientRegistrationMetadata(): array $clientMetadata[ClaimsEnum::SoftwareId->value] = 'https://github.com/cicnavi/oidc-client-php'; } + if ($this->postLogoutRedirectUris !== []) { + $clientMetadata[ClaimsEnum::PostLogoutRedirectUris->value] = array_values( + $this->postLogoutRedirectUris, + ); + } + return array_merge($clientMetadata, $this->additionalClientMetadata); } diff --git a/src/FederatedClient.php b/src/FederatedClient.php index 18a9bc1..9ed1c6e 100644 --- a/src/FederatedClient.php +++ b/src/FederatedClient.php @@ -813,8 +813,10 @@ public function autoRegisterAndAuthenticate( } /** - * Deliver the front-channel authorization request to the OP, either as an - * auto-submitting POST form or as a redirect, depending on the method. + * Deliver a front-channel request (authorization request, RP-Initiated + * Logout request) to the OP, either as an auto-submitting POST form or + * as a redirect with parameters in the query string, depending on the + * method. * * @param array $authorizationParameters */ @@ -839,7 +841,7 @@ protected function dispatchAuthorizationRequest( $opAuthorizationEndpointUri = $opAuthorizationEndpoint . '?' . http_build_query($authorizationParameters); if ($response instanceof ResponseInterface) { - $this->logger?->debug('Redirecting to authorization endpoint.'); + $this->logger?->debug('Redirecting.', ['endpoint' => $opAuthorizationEndpoint]); return $response->withHeader('Location', $opAuthorizationEndpointUri); } @@ -901,6 +903,12 @@ public function getUserData(?ServerRequestInterface $request = null): array $opUserinfoEndpoint = $resolvedOpMetadata[ClaimsEnum::UserinfoEndpoint->value] ?? null; $opUserinfoEndpoint = is_string($opUserinfoEndpoint) ? $opUserinfoEndpoint : null; + // Snapshot the end session endpoint at login time, since the resolved + // OP metadata (bound to the authorization flow state) is not + // available anymore when logout is performed. + $opEndSessionEndpoint = $resolvedOpMetadata[ClaimsEnum::EndSessionEndpoint->value] ?? null; + $opEndSessionEndpoint = is_string($opEndSessionEndpoint) ? $opEndSessionEndpoint : null; + $opEntityId = $resolvedOpMetadata[ClaimsEnum::Issuer->value] ?? null; if (!is_string($opEntityId)) { $this->logger?->error( @@ -923,9 +931,111 @@ public function getUserData(?ServerRequestInterface $request = null): array useNonce: $this->useNonce, fetchUserinfoClaims: $this->fetchUserinfoClaims, expectedIssuer: $opEntityId, + opEndSessionEndpoint: $opEndSessionEndpoint, ); } + /** + * Perform RP-Initiated Logout: remove the login data persisted in the + * session store (local logout) and deliver a logout request to the OP's + * end session endpoint (as advertised at login time), carrying the ID + * token received at login as 'id_token_hint'. + * + * Note that this does not destroy the application session itself - the + * application should do that as part of its own logout handling. + * + * @param ?string $postLogoutRedirectUri URI to which the OP should + * redirect the user agent after logout. Must be registered as one of + * this RP's 'post_logout_redirect_uris' metadata values (which can be + * provided using the Relying Party configuration additional claims). + * Validate the redirected request using validateLogoutCallback(). + * @param ?string $logoutHint Hint about the End-User that is logging out, + * analogous to 'login_hint' (e.g., e-mail address or phone number). + * @param ?string $uiLocales Preferred languages for the OP's logout user + * interface (space-separated language tags). + * @param AuthorizationRequestMethodEnum $logoutRequestMethod How to + * deliver the logout request to the OP. Defaults to Query (HTTP GET + * redirect), which every OP supporting RP-Initiated Logout accepts. + * @param ?ResponseInterface $response Optional HTTP response which will + * be populated with proper headers and returned. If not provided, an + * immediate redirect (or form output) is performed. + * @throws OidcClientException If no end session endpoint is available in + * the persisted login data (the OP did not advertise one at login time, + * or no login was performed in this session). + */ + public function logout( + ?string $postLogoutRedirectUri = null, + ?string $logoutHint = null, + ?string $uiLocales = null, + AuthorizationRequestMethodEnum $logoutRequestMethod = AuthorizationRequestMethodEnum::Query, + ?ResponseInterface $response = null, + ): ?ResponseInterface { + $endSessionEndpoint = $this->requestDataHandler->getLoginEndSessionEndpoint(); + + if (!is_string($endSessionEndpoint)) { + $error = 'End session endpoint not available in persisted login data, so RP-Initiated Logout is ' . + 'not available (the OpenID Provider did not advertise one at login time, or no login was performed).'; + $this->logger?->error($error); + throw new OidcClientException($error); + } + + $parameters = $this->requestDataHandler->buildEndSessionParameters( + idTokenHint: $this->requestDataHandler->getLoginIdToken(), + clientId: $this->entityConfig->getEntityId(), + postLogoutRedirectUri: $postLogoutRedirectUri, + state: $this->requestDataHandler->getLogoutState(), + logoutHint: $logoutHint, + uiLocales: $uiLocales, + ); + + $this->logger?->debug('Logout request parameters', $parameters); + + // Local logout: remove persisted login data. + $this->requestDataHandler->clearLoginData(); + + return $this->dispatchAuthorizationRequest( + $endSessionEndpoint, + $parameters, + $logoutRequestMethod, + $response, + ); + } + + /** + * Validate the request made to the post logout redirect URI after an + * RP-Initiated Logout (the OP must return the logout state parameter + * unchanged). + * + * @throws OidcClientException If the state parameter is missing or does + * not match the one sent in the logout request. + */ + public function validateLogoutCallback(?ServerRequestInterface $request = null): void + { + $this->requestDataHandler->validateLogoutCallbackResponse($request); + } + + /** + * Raw ID token received at the last successful login, or null when not + * available (no login was performed, no ID token was issued, or the + * session expired). + */ + public function getIdToken(): ?string + { + return $this->requestDataHandler->getLoginIdToken(); + } + + /** + * Login data persisted at the last successful login (raw ID token, its + * 'iss' / 'sub' / 'sid' claims, OP end session endpoint), or null when + * not available. + * + * @return mixed[]|null + */ + public function getLoginData(): ?array + { + return $this->requestDataHandler->getLoginData(); + } + /** * Build a signed 'private_key_jwt' client assertion for back-channel client * authentication (token endpoint, PAR endpoint...). The audience is the diff --git a/src/PreRegisteredClient.php b/src/PreRegisteredClient.php index 42e01a2..f9519cd 100644 --- a/src/PreRegisteredClient.php +++ b/src/PreRegisteredClient.php @@ -266,8 +266,29 @@ public function authorize( ]; } - if ($authorizationRequestMethod === AuthorizationRequestMethodEnum::FormPost) { - $formHtml = HttpHelper::generateAutoSubmitPostForm($authorizationEndpoint, $parameters); + return $this->dispatchFrontChannelRequest( + $authorizationEndpoint, + $parameters, + $authorizationRequestMethod, + $response, + ); + } + + /** + * Deliver a front-channel request (authorization request, RP-Initiated + * Logout request) to the OP, either as an auto-submitting POST form or as + * a redirect with parameters in the query string. + * + * @param array $parameters + */ + protected function dispatchFrontChannelRequest( + string $endpoint, + array $parameters, + AuthorizationRequestMethodEnum $requestMethod, + ?ResponseInterface $response, + ): ?ResponseInterface { + if ($requestMethod === AuthorizationRequestMethodEnum::FormPost) { + $formHtml = HttpHelper::generateAutoSubmitPostForm($endpoint, $parameters); if ($response instanceof ResponseInterface) { $this->logger?->debug('Returning FormPost HTML in response body.'); $response->getBody()->write($formHtml); @@ -278,10 +299,10 @@ public function authorize( exit; } - $redirectUri = $authorizationEndpoint . '?' . http_build_query($parameters); + $redirectUri = $endpoint . '?' . http_build_query($parameters); if ($response instanceof ResponseInterface) { - $this->logger?->debug('Redirecting to authorization endpoint.'); + $this->logger?->debug('Redirecting.', ['endpoint' => $endpoint]); return $response->withHeader('Location', $redirectUri); } @@ -336,9 +357,124 @@ public function getUserData(?ServerRequestInterface $request = null): array useNonce: $this->useNonce, fetchUserinfoClaims: $this->fetchUserinfoClaims, expectedIssuer: $expectedIssuer, + opEndSessionEndpoint: $this->getOptionalMetadataString(ClaimsEnum::EndSessionEndpoint->value), + ); + } + + /** + * Perform RP-Initiated Logout: remove the login data persisted in the + * session store (local logout) and deliver a logout request to the OP's + * end session endpoint, carrying the ID token received at login as + * 'id_token_hint'. + * + * Note that this does not destroy the application session itself - the + * application should do that as part of its own logout handling. + * + * @param ?string $postLogoutRedirectUri URI to which the OP should + * redirect the user agent after logout. Must be registered on the OP as + * one of this client's 'post_logout_redirect_uris'. Validate the + * redirected request using validateLogoutCallback(). + * @param ?string $logoutHint Hint about the End-User that is logging out, + * analogous to 'login_hint' (e.g., e-mail address or phone number). + * @param ?string $uiLocales Preferred languages for the OP's logout user + * interface (space-separated language tags). + * @param AuthorizationRequestMethodEnum $logoutRequestMethod How to + * deliver the logout request to the OP. Defaults to Query (HTTP GET + * redirect), which every OP supporting RP-Initiated Logout accepts. + * @param ?ResponseInterface $response Optional HTTP response which will + * be populated with proper headers and returned. If not provided, an + * immediate redirect (or form output) is performed. + * @throws OidcClientException If the OP does not advertise an + * 'end_session_endpoint'. + */ + public function logout( + ?string $postLogoutRedirectUri = null, + ?string $logoutHint = null, + ?string $uiLocales = null, + AuthorizationRequestMethodEnum $logoutRequestMethod = AuthorizationRequestMethodEnum::Query, + ?ResponseInterface $response = null, + ): ?ResponseInterface { + $endSessionEndpoint = $this->requestDataHandler->getLoginEndSessionEndpoint() ?? + $this->getOptionalMetadataString(ClaimsEnum::EndSessionEndpoint->value); + + if (!is_string($endSessionEndpoint)) { + throw new OidcClientException( + 'End session endpoint not found in OP metadata, so RP-Initiated Logout is not available.', + ); + } + + $parameters = $this->requestDataHandler->buildEndSessionParameters( + idTokenHint: $this->requestDataHandler->getLoginIdToken(), + clientId: $this->clientId, + postLogoutRedirectUri: $postLogoutRedirectUri, + state: $this->useState ? $this->requestDataHandler->getLogoutState() : null, + logoutHint: $logoutHint, + uiLocales: $uiLocales, + ); + + $this->logger?->debug('Logout request parameters', $parameters); + + // Local logout: remove persisted login data. + $this->requestDataHandler->clearLoginData(); + + return $this->dispatchFrontChannelRequest( + $endSessionEndpoint, + $parameters, + $logoutRequestMethod, + $response, ); } + /** + * Validate the request made to the post logout redirect URI after an + * RP-Initiated Logout (the OP must return the logout state parameter + * unchanged). No-op when this client is configured not to use state. + * + * @throws OidcClientException If the state parameter is missing or does + * not match the one sent in the logout request. + */ + public function validateLogoutCallback(?ServerRequestInterface $request = null): void + { + $this->requestDataHandler->validateLogoutCallbackResponse($request, $this->useState); + } + + /** + * Raw ID token received at the last successful login, or null when not + * available (no login was performed, no ID token was issued, or the + * session expired). + */ + public function getIdToken(): ?string + { + return $this->requestDataHandler->getLoginIdToken(); + } + + /** + * Login data persisted at the last successful login (raw ID token, its + * 'iss' / 'sub' / 'sid' claims, OP end session endpoint), or null when + * not available. + * + * @return mixed[]|null + */ + public function getLoginData(): ?array + { + return $this->requestDataHandler->getLoginData(); + } + + /** + * Read an optional string value from OP metadata, returning null when the + * key is not advertised or its value is not a non-empty string. + */ + protected function getOptionalMetadataString(string $key): ?string + { + try { + $value = $this->metadata->get($key); + } catch (OidcClientException) { + return null; + } + + return (is_string($value) && $value !== '') ? $value : null; + } + /** * @return MetadataInterface OIDC Configuration URL content (OIDC metadata). */ diff --git a/src/Protocol/RequestDataHandler.php b/src/Protocol/RequestDataHandler.php index 683f389..d8f4f80 100644 --- a/src/Protocol/RequestDataHandler.php +++ b/src/Protocol/RequestDataHandler.php @@ -41,6 +41,13 @@ class RequestDataHandler public const KEY_REDIRECT_URI_FOR_STATE_ = 'redirect_uri_for_state_'; + /** + * Session store key under which login data needed for logout (raw ID + * token, its iss / sub / sid claims, OP end session endpoint) is + * persisted after a successful login. + */ + public const KEY_LOGIN_DATA = 'oidc_login_data'; + protected StateNonceDataHandlerInterface $stateNonceDataHandler; protected PkceDataHandlerInterface $pkceDataHandler; @@ -124,6 +131,7 @@ public function getUserData( bool $useNonce = true, bool $fetchUserinfoClaims = true, ?string $expectedIssuer = null, + ?string $opEndSessionEndpoint = null, ): array { $tokenData = $this->requestTokenData( @@ -144,7 +152,7 @@ public function getUserData( $this->pkceDataHandler->removeCodeVerifier(); } - return $this->getClaims( + $claims = $this->getClaims( tokenData: $tokenData, jwksUri: $opJwksUri, userinfoEndpoint: $opUserinfoEndpoint, @@ -153,6 +161,13 @@ public function getUserData( expectedIssuer: $expectedIssuer, expectedClientId: $clientId, ); + + $this->storeLoginData( + $tokenData[ParamsEnum::IdToken->value], + $opEndSessionEndpoint, + ); + + return $claims; } /** @@ -926,4 +941,182 @@ public function getClientRedirectUriForState(string $state): string throw new OidcClientException('Redirect URI not found for state "' . $state . '".'); } + + /** + * Persist login data needed for logout in the session store: the raw ID + * token (used as 'id_token_hint' in RP-Initiated Logout), its 'iss', + * 'sub' and 'sid' claims (used to correlate OIDC Back-Channel Logout + * requests with this login), and the OP's end session endpoint (so + * logout can be performed even when OP metadata is no longer at hand, + * e.g. for OPs resolved per authorization flow). + * + * Claim extraction is best-effort: the ID token was already validated + * during login, so an extraction error is only logged and the raw ID + * token is stored anyway. + */ + public function storeLoginData(?string $idToken, ?string $opEndSessionEndpoint = null): void + { + $claims = []; + + if (is_string($idToken)) { + try { + $claims = $this->core->idTokenFactory()->fromToken($idToken)->getPayload(); + } catch (Throwable $throwable) { + $this->logger?->warning( + 'Error extracting claims from ID token while storing login data. ' . $throwable->getMessage(), + ); + } + } + + $this->sessionStore->put(self::KEY_LOGIN_DATA, [ + ParamsEnum::IdToken->value => $idToken, + ClaimsEnum::Iss->value => is_string($iss = $claims[ClaimsEnum::Iss->value] ?? null) ? $iss : null, + ClaimsEnum::Sub->value => is_string($sub = $claims[ClaimsEnum::Sub->value] ?? null) ? $sub : null, + ClaimsEnum::Sid->value => is_string($sid = $claims[ClaimsEnum::Sid->value] ?? null) ? $sid : null, + ClaimsEnum::EndSessionEndpoint->value => $opEndSessionEndpoint, + ]); + } + + /** + * Get the login data persisted after the last successful login, or null + * when not available (no login was performed, or the session expired). + * + * @return mixed[]|null + */ + public function getLoginData(): ?array + { + $loginData = $this->sessionStore->get(self::KEY_LOGIN_DATA); + + return is_array($loginData) ? $loginData : null; + } + + /** + * Raw ID token received at login, usable as the 'id_token_hint' + * RP-Initiated Logout parameter. + */ + public function getLoginIdToken(): ?string + { + return $this->getLoginDataStringValue(ParamsEnum::IdToken->value); + } + + /** + * Issuer (iss) claim of the ID token received at login. + */ + public function getLoginIssuer(): ?string + { + return $this->getLoginDataStringValue(ClaimsEnum::Iss->value); + } + + /** + * Subject (sub) claim of the ID token received at login. + */ + public function getLoginSubject(): ?string + { + return $this->getLoginDataStringValue(ClaimsEnum::Sub->value); + } + + /** + * Session ID (sid) claim of the ID token received at login, if the OP + * issued one. Used to correlate OP-initiated (back-channel) logout + * requests with this login. + */ + public function getLoginSessionId(): ?string + { + return $this->getLoginDataStringValue(ClaimsEnum::Sid->value); + } + + /** + * The OP's end session endpoint as advertised at login time. + */ + public function getLoginEndSessionEndpoint(): ?string + { + return $this->getLoginDataStringValue(ClaimsEnum::EndSessionEndpoint->value); + } + + /** + * Remove persisted login data from the session store (local logout). + */ + public function clearLoginData(): void + { + $this->sessionStore->delete(self::KEY_LOGIN_DATA); + } + + protected function getLoginDataStringValue(string $key): ?string + { + $value = $this->getLoginData()[$key] ?? null; + + return (is_string($value) && $value !== '') ? $value : null; + } + + /** + * Get the state parameter value to use in an RP-Initiated Logout + * request. Stored in the session (separately from the authorization + * request state), so it can be verified on the post logout redirect. + * + * @throws OidcClientException + */ + public function getLogoutState(): string + { + return $this->stateNonceDataHandler->get(StateNonce::LOGOUT_STATE_KEY); + } + + /** + * Build RP-Initiated Logout request parameters for the OP's end session + * endpoint. Null parameters are omitted. Per the specification all + * parameters are optional, but 'id_token_hint' is recommended, and when + * 'post_logout_redirect_uri' is used the OP needs to identify the RP + * ('id_token_hint' and / or 'client_id'), and its value must have been + * registered on the OP as one of the client's + * 'post_logout_redirect_uris'. + * + * @return array + */ + public function buildEndSessionParameters( + ?string $idTokenHint = null, + ?string $clientId = null, + ?string $postLogoutRedirectUri = null, + ?string $state = null, + ?string $logoutHint = null, + ?string $uiLocales = null, + ): array { + return array_filter([ + ParamsEnum::IdTokenHint->value => $idTokenHint, + ParamsEnum::ClientId->value => $clientId, + ParamsEnum::PostLogoutRedirectUri->value => $postLogoutRedirectUri, + ParamsEnum::State->value => $state, + ParamsEnum::LogoutHint->value => $logoutHint, + ParamsEnum::UiLocales->value => $uiLocales, + ]); + } + + /** + * Validate the request made to the post logout redirect URI after an + * RP-Initiated Logout: when a state parameter was sent in the logout + * request, the OP must return it unchanged, so verify it against the + * stored logout state (which is removed on successful verification). + * + * @throws OidcClientException + */ + public function validateLogoutCallbackResponse( + ?ServerRequestInterface $request = null, + bool $useState = true, + ): void { + if (!$useState) { + return; + } + + $queryParams = $request?->getQueryParams() ?? $_GET; + $parsedBody = $request?->getParsedBody() ?? $_POST; + $params = array_merge( + $queryParams, + is_array($parsedBody) ? $parsedBody : [] + ); + + $state = $params[ParamsEnum::State->value] ?? null; + if (!is_string($state) || $state === '') { + throw new OidcClientException('Not all required parameters were provided (state).'); + } + + $this->stateNonceDataHandler->verify(StateNonce::LOGOUT_STATE_KEY, $state); + } } diff --git a/tests/Oidc/DataStore/DataHandlers/StateNonceTest.php b/tests/Oidc/DataStore/DataHandlers/StateNonceTest.php index 84ddf1d..21e99d2 100644 --- a/tests/Oidc/DataStore/DataHandlers/StateNonceTest.php +++ b/tests/Oidc/DataStore/DataHandlers/StateNonceTest.php @@ -79,4 +79,30 @@ public function testGetNewValue(): void $this->assertSame($value, $stateNonce->get(StateNonce::STATE_KEY)); } + + public function testLogoutStateIsValidKeyAndSeparateFromState(): void + { + $stateNonce = new StateNonce(); + + $state = $stateNonce->get(StateNonce::STATE_KEY); + $logoutState = $stateNonce->get(StateNonce::LOGOUT_STATE_KEY); + + $this->assertNotSame($state, $logoutState); + + $stateNonce->verify(StateNonce::LOGOUT_STATE_KEY, $logoutState); + + // Logout state is removed on successful verification, so a new one is + // generated on next get. + $this->assertNotSame($logoutState, $stateNonce->get(StateNonce::LOGOUT_STATE_KEY)); + // Authorization request state is not affected by logout state handling. + $this->assertSame($state, $stateNonce->get(StateNonce::STATE_KEY)); + } + + public function testVerifyLogoutStateInvalidValueThrows(): void + { + $stateNonce = new StateNonce(); + $stateNonce->get(StateNonce::LOGOUT_STATE_KEY); + $this->expectException(\Exception::class); + $stateNonce->verify(StateNonce::LOGOUT_STATE_KEY, 'invalid'); + } } diff --git a/tests/Oidc/DynamicallyRegisteredClientTest.php b/tests/Oidc/DynamicallyRegisteredClientTest.php index 64cf706..19b767b 100644 --- a/tests/Oidc/DynamicallyRegisteredClientTest.php +++ b/tests/Oidc/DynamicallyRegisteredClientTest.php @@ -84,6 +84,7 @@ protected function sut( ?ClientRegistrationHandler $registrationHandler = null, ?PreRegisteredClient $preRegisteredClient = null, bool $injectPreRegisteredClient = true, + array $postLogoutRedirectUris = [], ): DynamicallyRegisteredClient { $registrationStore ??= $this->registrationStoreMock; $cache ??= $this->cacheMock; @@ -114,6 +115,7 @@ protected function sut( metadata: $metadata, registrationHandler: $registrationHandler, preRegisteredClient: $preRegisteredClient, + postLogoutRedirectUris: $postLogoutRedirectUris, ); } @@ -466,6 +468,103 @@ public function testAuthorizeDelegatesToPreRegisteredClient(): void $this->assertNotInstanceOf(\Psr\Http\Message\ResponseInterface::class, $this->sut()->authorize()); } + public function testPostLogoutRedirectUrisAreIncludedInClientRegistrationMetadata(): void + { + $clientMetadata = $this->sut( + postLogoutRedirectUris: ['https://rp.example.org/logged-out'], + )->buildClientRegistrationMetadata(); + + $this->assertSame( + ['https://rp.example.org/logged-out'], + $clientMetadata['post_logout_redirect_uris'], + ); + } + + public function testPostLogoutRedirectUrisAreAbsentByDefault(): void + { + $this->assertArrayNotHasKey( + 'post_logout_redirect_uris', + $this->sut()->buildClientRegistrationMetadata(), + ); + } + + public function testThrowsForInvalidPostLogoutRedirectUri(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Post logout redirect URIs must be non-empty strings.'); + + $this->sut(postLogoutRedirectUris: ['']); + } + + public function testThrowsWhenPostLogoutRedirectUrisOverrideExcludesConfiguredUri(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('post_logout_redirect_uris'); + + $this->sut( + additionalClientMetadata: [ + 'post_logout_redirect_uris' => ['https://rp.example.org/other'], + ], + postLogoutRedirectUris: ['https://rp.example.org/logged-out'], + ); + } + + public function testLogoutDelegatesToPreRegisteredClient(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->preRegisteredClientMock->expects($this->once()) + ->method('logout') + ->with( + 'https://rp.example.org/logged-out', + null, + null, + \Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum::Query, + null, + ) + ->willReturn(null); + + $this->assertNotInstanceOf( + \Psr\Http\Message\ResponseInterface::class, + $this->sut()->logout('https://rp.example.org/logged-out'), + ); + } + + public function testValidateLogoutCallbackDelegatesToPreRegisteredClient(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->preRegisteredClientMock->expects($this->once())->method('validateLogoutCallback'); + + $this->sut()->validateLogoutCallback(); + } + + public function testGetIdTokenDelegatesToPreRegisteredClient(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->preRegisteredClientMock->method('getIdToken')->willReturn('id-token'); + + $this->assertSame('id-token', $this->sut()->getIdToken()); + } + + public function testGetLoginDataDelegatesToPreRegisteredClient(): void + { + $this->registrationStoreMock->method('get')->willReturn( + $this->clientInformationResponseWithCurrentFingerprint(), + ); + + $this->preRegisteredClientMock->method('getLoginData')->willReturn(['id_token' => 'id-token']); + + $this->assertSame(['id_token' => 'id-token'], $this->sut()->getLoginData()); + } + public function testGetUserDataDelegatesToPreRegisteredClient(): void { $this->registrationStoreMock->method('get')->willReturn( diff --git a/tests/Oidc/FederatedClientTest.php b/tests/Oidc/FederatedClientTest.php index 279aac7..fccad4a 100644 --- a/tests/Oidc/FederatedClientTest.php +++ b/tests/Oidc/FederatedClientTest.php @@ -568,6 +568,140 @@ public function testGetUserDataSuccess(): void $this->assertSame($expectedUserData, $result); } + public function testGetUserDataPassesEndSessionEndpointFromResolvedOpMetadata(): void + { + $opEntityId = 'https://op.example.org'; + $state = 'state123'; + $this->requestDataHandlerMock->method('validateAuthorizationCallbackResponse')->willReturn([ + 'code' => 'auth_code', + 'state' => $state, + ]); + + $opMetadata = [ + 'jwks_uri' => 'https://op.example.org/jwks', + 'token_endpoint' => 'https://op.example.org/token', + 'issuer' => $opEntityId, + 'end_session_endpoint' => 'https://op.example.org/end-session', + ]; + $this->requestDataHandlerMock->method('getResolvedOpMetadataForState') + ->with($state)->willReturn($opMetadata); + $this->requestDataHandlerMock->method('getClientRedirectUriForState') + ->with($state)->willReturn('https://rp.example.org/callback'); + + $keyPairResolverMock = $this->createMock(\SimpleSAML\OpenID\Utils\KeyPairResolver::class); + $this->federationMock->method('keyPairResolver')->willReturn($keyPairResolverMock); + $signingKeyPairMock = $this->createMock(SignatureKeyPair::class); + $keyPairResolverMock->method('resolveSignatureKeyPairByAlgorithm')->willReturn($signingKeyPairMock); + + $innerKeyPairMock = $this->createMock(\SimpleSAML\OpenID\ValueAbstracts\KeyPair::class); + $signingKeyPairMock->method('getKeyPair')->willReturn($innerKeyPairMock); + $innerKeyPairMock->method('getKeyId')->willReturn('kid1'); + $innerKeyPairMock->method('getPrivateKey')->willReturn($this->createStub(JwkDecorator::class)); + $signingKeyPairMock->method('getSignatureAlgorithm')->willReturn(SignatureAlgorithmEnum::ES256); + + $helpersMock = $this->createMock(\SimpleSAML\OpenID\Helpers::class); + $this->federationMock->method('helpers')->willReturn($helpersMock); + $dateTimeHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\DateTime::class); + $helpersMock->method('dateTime')->willReturn($dateTimeHelperMock); + $dateTimeHelperMock->method('getUtc')->willReturn(new \DateTimeImmutable()); + $randomHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\Random::class); + $helpersMock->method('random')->willReturn($randomHelperMock); + $randomHelperMock->method('string')->willReturn('random_jti'); + + $clientAssertionMock = $this->createMock(ClientAssertion::class); + $clientAssertionFactoryMock = $this->createMock(ClientAssertionFactory::class); + $this->coreMock->method('clientAssertionFactory')->willReturn($clientAssertionFactoryMock); + $clientAssertionFactoryMock->method('fromData')->willReturn($clientAssertionMock); + $clientAssertionMock->method('getToken')->willReturn('client_assertion_token'); + + $capturedArgs = []; + $this->requestDataHandlerMock->method('getUserData') + ->willReturnCallback(function (mixed ...$args) use (&$capturedArgs): array { + $capturedArgs = $args; + return ['sub' => 'user123']; + }); + + $this->sut()->getUserData(); + + $this->assertContains('https://op.example.org/end-session', $capturedArgs); + } + + public function testLogoutRedirectWithResponse(): void + { + $this->entityConfigMock->method('getEntityId')->willReturn('https://rp.example.org'); + + $this->requestDataHandlerMock->method('getLoginEndSessionEndpoint') + ->willReturn('https://op.example.org/end-session'); + $this->requestDataHandlerMock->method('getLoginIdToken')->willReturn('id-token'); + $this->requestDataHandlerMock->method('getLogoutState')->willReturn('logout-state'); + $this->requestDataHandlerMock->expects($this->once()) + ->method('buildEndSessionParameters') + ->with( + 'id-token', + 'https://rp.example.org', + null, + 'logout-state', + null, + null, + ) + ->willReturn([ + 'id_token_hint' => 'id-token', + 'client_id' => 'https://rp.example.org', + 'state' => 'logout-state', + ]); + + $this->requestDataHandlerMock->expects($this->once())->method('clearLoginData'); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->expects($this->once()) + ->method('withHeader') + ->with( + 'Location', + $this->callback(fn(string $location): bool => str_starts_with( + $location, + 'https://op.example.org/end-session?' + ) && + str_contains($location, 'id_token_hint=id-token') && + str_contains($location, 'client_id=' . urlencode('https://rp.example.org')) && + str_contains($location, 'state=logout-state')) + ) + ->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->logout(response: $responseMock)); + } + + public function testLogoutThrowsWhenEndSessionEndpointNotAvailable(): void + { + $this->requestDataHandlerMock->method('getLoginEndSessionEndpoint')->willReturn(null); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('End session endpoint not available'); + + $this->sut()->logout(); + } + + public function testValidateLogoutCallbackDelegates(): void + { + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutCallbackResponse'); + + $this->sut()->validateLogoutCallback(); + } + + public function testGetIdTokenDelegates(): void + { + $this->requestDataHandlerMock->method('getLoginIdToken')->willReturn('id-token'); + + $this->assertSame('id-token', $this->sut()->getIdToken()); + } + + public function testGetLoginDataDelegates(): void + { + $this->requestDataHandlerMock->method('getLoginData')->willReturn(['id_token' => 'id-token']); + + $this->assertSame(['id_token' => 'id-token'], $this->sut()->getLoginData()); + } + public function testBuildEntityStatementWithStaticTrustMarks(): void { $entityId = 'https://rp.example.org'; diff --git a/tests/Oidc/PreRegisteredClientTest.php b/tests/Oidc/PreRegisteredClientTest.php index 7aef423..e84b684 100644 --- a/tests/Oidc/PreRegisteredClientTest.php +++ b/tests/Oidc/PreRegisteredClientTest.php @@ -281,11 +281,15 @@ public function testGetUserDataSuccess(): void \SimpleSAML\OpenID\Codebooks\ParamsEnum::Code->value => 'auth-code-123', ]); - $this->metadataMock->expects($this->exactly(4))->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(5))->method('get')->willReturnMap([ [\SimpleSAML\OpenID\Codebooks\ClaimsEnum::JwksUri->value, 'https://op.example.org/jwks'], [\SimpleSAML\OpenID\Codebooks\ClaimsEnum::TokenEndpoint->value, 'https://op.example.org/token'], [\SimpleSAML\OpenID\Codebooks\ClaimsEnum::UserinfoEndpoint->value, 'https://op.example.org/userinfo'], [\SimpleSAML\OpenID\Codebooks\ClaimsEnum::Issuer->value, 'https://op.example.org'], + [ + \SimpleSAML\OpenID\Codebooks\ClaimsEnum::EndSessionEndpoint->value, + 'https://op.example.org/end-session', + ], ]); $expected = ['sub' => 'user-1']; @@ -508,4 +512,181 @@ public function testAuthorizeFormPostWithResponseMode(): void ); $this->assertSame($response, $result); } + + public function testLogoutRedirectWithResponse(): void + { + $this->metadataMock->expects($this->exactly(1))->method('get')->willReturnMap([ + ['end_session_endpoint', 'https://op.example.org/end-session'], + ]); + + $this->requestDataHandlerMock->method('getLoginEndSessionEndpoint')->willReturn(null); + $this->requestDataHandlerMock->method('getLoginIdToken')->willReturn('id-token'); + $this->requestDataHandlerMock->method('getLogoutState')->willReturn('logout-state'); + $this->requestDataHandlerMock->expects($this->once()) + ->method('buildEndSessionParameters') + ->with( + 'id-token', + $this->clientId, + 'https://rp.example.org/logged-out', + 'logout-state', + null, + null, + ) + ->willReturn([ + 'id_token_hint' => 'id-token', + 'client_id' => $this->clientId, + 'post_logout_redirect_uri' => 'https://rp.example.org/logged-out', + 'state' => 'logout-state', + ]); + + $this->requestDataHandlerMock->expects($this->once())->method('clearLoginData'); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once()) + ->method('withHeader') + ->with( + 'Location', + $this->callback(fn(string $location): bool => str_starts_with( + $location, + 'https://op.example.org/end-session?' + ) && + str_contains($location, 'id_token_hint=id-token') && + str_contains($location, 'client_id=' . urlencode($this->clientId)) && + str_contains( + $location, + 'post_logout_redirect_uri=' . urlencode('https://rp.example.org/logged-out') + ) && + str_contains($location, 'state=logout-state')) + ) + ->willReturn($response); + + $result = $this->sut()->logout( + postLogoutRedirectUri: 'https://rp.example.org/logged-out', + response: $response, + ); + $this->assertSame($response, $result); + } + + public function testLogoutUsesEndSessionEndpointFromLoginData(): void + { + $this->requestDataHandlerMock->method('getLoginEndSessionEndpoint') + ->willReturn('https://op.example.org/end-session-from-login'); + $this->metadataMock->expects($this->never())->method('get'); + + $this->requestDataHandlerMock->method('getLogoutState')->willReturn('logout-state'); + $this->requestDataHandlerMock->method('buildEndSessionParameters')->willReturn([]); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once()) + ->method('withHeader') + ->with( + 'Location', + $this->callback(fn(string $location): bool => str_starts_with( + $location, + 'https://op.example.org/end-session-from-login' + )) + ) + ->willReturn($response); + + $result = $this->sut()->logout(response: $response); + $this->assertSame($response, $result); + } + + public function testLogoutThrowsWhenEndSessionEndpointNotAvailable(): void + { + $this->requestDataHandlerMock->method('getLoginEndSessionEndpoint')->willReturn(null); + $this->metadataMock->method('get')->willThrowException( + new \Cicnavi\Oidc\Exceptions\OidcClientException('OIDC metadata parameter not supported'), + ); + + $this->expectException(\Cicnavi\Oidc\Exceptions\OidcClientException::class); + $this->expectExceptionMessage('End session endpoint not found in OP metadata'); + + $this->sut()->logout(); + } + + public function testLogoutFormPostWithResponse(): void + { + $this->metadataMock->expects($this->exactly(1))->method('get')->willReturnMap([ + ['end_session_endpoint', 'https://op.example.org/end-session'], + ]); + + $this->requestDataHandlerMock->method('getLogoutState')->willReturn('logout-state'); + $this->requestDataHandlerMock->method('buildEndSessionParameters')->willReturn([ + 'id_token_hint' => 'id-token', + ]); + + $body = $this->createMock(\Psr\Http\Message\StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->callback(fn(string $html): bool => str_contains($html, 'createMock(\Psr\Http\Message\ResponseInterface::class); + $response->method('getBody')->willReturn($body); + $response->expects($this->once()) + ->method('withHeader') + ->with('Content-Type', 'text/html') + ->willReturn($response); + + $result = $this->sut()->logout( + logoutRequestMethod: AuthorizationRequestMethodEnum::FormPost, + response: $response, + ); + $this->assertSame($response, $result); + } + + public function testLogoutWithoutStateOmitsLogoutState(): void + { + $this->metadataMock->expects($this->exactly(1))->method('get')->willReturnMap([ + ['end_session_endpoint', 'https://op.example.org/end-session'], + ]); + + $this->requestDataHandlerMock->expects($this->never())->method('getLogoutState'); + $this->requestDataHandlerMock->expects($this->once()) + ->method('buildEndSessionParameters') + ->with(null, $this->clientId, null, null, null, null) + ->willReturn([]); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->method('withHeader')->willReturn($response); + + $result = $this->sut(useState: false)->logout(response: $response); + $this->assertSame($response, $result); + } + + public function testValidateLogoutCallbackDelegates(): void + { + $request = $this->createStub(\Psr\Http\Message\ServerRequestInterface::class); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutCallbackResponse') + ->with($request, true); + + $this->sut()->validateLogoutCallback($request); + } + + public function testValidateLogoutCallbackHonorsUseStateSetting(): void + { + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutCallbackResponse') + ->with(null, false); + + $this->sut(useState: false)->validateLogoutCallback(); + } + + public function testGetIdTokenDelegates(): void + { + $this->requestDataHandlerMock->method('getLoginIdToken')->willReturn('id-token'); + + $this->assertSame('id-token', $this->sut()->getIdToken()); + } + + public function testGetLoginDataDelegates(): void + { + $this->requestDataHandlerMock->method('getLoginData')->willReturn(['id_token' => 'id-token']); + + $this->assertSame(['id_token' => 'id-token'], $this->sut()->getLoginData()); + } } diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index e07bbcb..8c64e3a 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -1358,4 +1358,284 @@ public function testValidatePushedAuthorizationResponseDataDefaultsExpiresInToZe $this->sut()->validatePushedAuthorizationResponseData(['request_uri' => 'urn:abc']), ); } + + public function testGetLogoutStateDelegatesToHandler(): void + { + $this->stateNonceDataHandlerMock->expects($this->once()) + ->method('get') + ->with(StateNonce::LOGOUT_STATE_KEY) + ->willReturn('logout-state'); + + $this->assertSame('logout-state', $this->sut()->getLogoutState()); + } + + public function testStoreLoginDataStoresIdTokenAndClaims(): void + { + $idTokenFactory = $this->createMock(IdTokenFactory::class); + $this->coreMock->method('idTokenFactory')->willReturn($idTokenFactory); + $idTokenJws = $this->createMock(IdToken::class); + $idTokenFactory->method('fromToken')->with('id-token')->willReturn($idTokenJws); + $idTokenJws->method('getPayload')->willReturn([ + 'iss' => 'https://op.example.org', + 'sub' => 'user-1', + 'sid' => 'op-session-1', + ]); + + $this->sessionStoreMock->expects($this->once()) + ->method('put') + ->with(RequestDataHandler::KEY_LOGIN_DATA, [ + 'id_token' => 'id-token', + 'iss' => 'https://op.example.org', + 'sub' => 'user-1', + 'sid' => 'op-session-1', + 'end_session_endpoint' => 'https://op.example.org/end-session', + ]); + + $this->sut()->storeLoginData('id-token', 'https://op.example.org/end-session'); + } + + public function testStoreLoginDataStoresRawIdTokenOnClaimExtractionError(): void + { + $idTokenFactory = $this->createMock(IdTokenFactory::class); + $this->coreMock->method('idTokenFactory')->willReturn($idTokenFactory); + $idTokenFactory->method('fromToken')->willThrowException(new JwsException('Parse error')); + + $this->loggerMock->expects($this->once())->method('warning'); + + $this->sessionStoreMock->expects($this->once()) + ->method('put') + ->with(RequestDataHandler::KEY_LOGIN_DATA, [ + 'id_token' => 'id-token', + 'iss' => null, + 'sub' => null, + 'sid' => null, + 'end_session_endpoint' => null, + ]); + + $this->sut()->storeLoginData('id-token'); + } + + public function testStoreLoginDataWithoutIdToken(): void + { + $this->coreMock->expects($this->never())->method('idTokenFactory'); + + $this->sessionStoreMock->expects($this->once()) + ->method('put') + ->with(RequestDataHandler::KEY_LOGIN_DATA, [ + 'id_token' => null, + 'iss' => null, + 'sub' => null, + 'sid' => null, + 'end_session_endpoint' => 'https://op.example.org/end-session', + ]); + + $this->sut()->storeLoginData(null, 'https://op.example.org/end-session'); + } + + public function testLoginDataGetters(): void + { + $this->sessionStoreMock->method('get') + ->with(RequestDataHandler::KEY_LOGIN_DATA) + ->willReturn([ + 'id_token' => 'id-token', + 'iss' => 'https://op.example.org', + 'sub' => 'user-1', + 'sid' => 'op-session-1', + 'end_session_endpoint' => 'https://op.example.org/end-session', + ]); + + $sut = $this->sut(); + + $this->assertSame('id-token', $sut->getLoginIdToken()); + $this->assertSame('https://op.example.org', $sut->getLoginIssuer()); + $this->assertSame('user-1', $sut->getLoginSubject()); + $this->assertSame('op-session-1', $sut->getLoginSessionId()); + $this->assertSame('https://op.example.org/end-session', $sut->getLoginEndSessionEndpoint()); + } + + public function testLoginDataGettersReturnNullWhenNoLoginData(): void + { + $this->sessionStoreMock->method('get')->willReturn(null); + + $sut = $this->sut(); + + $this->assertNull($sut->getLoginData()); + $this->assertNull($sut->getLoginIdToken()); + $this->assertNull($sut->getLoginIssuer()); + $this->assertNull($sut->getLoginSubject()); + $this->assertNull($sut->getLoginSessionId()); + $this->assertNull($sut->getLoginEndSessionEndpoint()); + } + + public function testClearLoginData(): void + { + $this->sessionStoreMock->expects($this->once()) + ->method('delete') + ->with(RequestDataHandler::KEY_LOGIN_DATA); + + $this->sut()->clearLoginData(); + } + + public function testGetUserDataStoresLoginData(): void + { + // requestTokenData mocks + $this->pkceDataHandlerMock->method('getCodeVerifier')->willReturn('verifier'); + $this->guzzleBridgeMock->method('psr7StreamFor')->willReturn($this->createStub(StreamInterface::class)); + + $tokenRequest = $this->createMock(RequestInterface::class); + $userInfoRequest = $this->createMock(RequestInterface::class); + $userInfoRequest->method('withHeader')->willReturn($userInfoRequest); + + $this->requestFactoryMock->method('createRequest') + ->willReturnOnConsecutiveCalls($tokenRequest, $userInfoRequest); + + $tokenRequest->method('withBody')->willReturn($tokenRequest); + $tokenRequest->method('withHeader')->willReturn($tokenRequest); + + $tokenResponse = $this->createMock(ResponseInterface::class); + $tokenResponse->method('getStatusCode')->willReturn(200); + $tokenStream = $this->createMock(StreamInterface::class); + $tokenStream->method('__toString')->willReturn(json_encode([ + 'access_token' => 'at', + 'token_type' => 'Bearer', + 'id_token' => 'id-token' + ])); + $tokenResponse->method('getBody')->willReturn($tokenStream); + + // getClaims mocks + $jwksFetcher = $this->createMock(JwksFetcher::class); + $this->jwksMock->method('jwksFetcher')->willReturn($jwksFetcher); + $keySet = $this->createMock(JwksDecorator::class); + $keySet->method('jsonSerialize')->willReturn(['keys' => []]); + $jwksFetcher->method('fromCacheOrJwksUri')->willReturn($keySet); + + $idTokenFactory = $this->createMock(IdTokenFactory::class); + $this->coreMock->method('idTokenFactory')->willReturn($idTokenFactory); + $idTokenJws = $this->createMock(IdToken::class); + $idTokenFactory->method('fromToken')->willReturn($idTokenJws); + $idTokenJws->method('getNonce')->willReturn('nonce'); + $idTokenJws->method('getPayload')->willReturn([ + 'iss' => 'https://op.example.org', + 'sub' => 'sub1', + 'sid' => 'op-session-1', + ]); + + $userInfoResponse = $this->createMock(ResponseInterface::class); + $userInfoResponse->method('getStatusCode')->willReturn(200); + $userInfoStream = $this->createMock(StreamInterface::class); + $userInfoStream->method('__toString')->willReturn('{"sub": "sub1"}'); + $userInfoResponse->method('getBody')->willReturn($userInfoStream); + + $this->httpClientMock->method('sendRequest') + ->willReturnOnConsecutiveCalls($tokenResponse, $userInfoResponse); + + // Login data is persisted after successful login. + $this->sessionStoreMock->expects($this->once()) + ->method('put') + ->with(RequestDataHandler::KEY_LOGIN_DATA, [ + 'id_token' => 'id-token', + 'iss' => 'https://op.example.org', + 'sub' => 'sub1', + 'sid' => 'op-session-1', + 'end_session_endpoint' => 'https://op.example.org/end-session', + ]); + + $this->sut()->getUserData( + ClientAuthenticationMethodsEnum::ClientSecretPost, + 'code', + 'client-id', + 'redirect-uri', + 'jwks-uri', + 'token-endpoint', + 'userinfo-endpoint', + opEndSessionEndpoint: 'https://op.example.org/end-session', + ); + } + + public function testBuildEndSessionParametersOmitsNullValues(): void + { + $this->assertSame([], $this->sut()->buildEndSessionParameters()); + + $this->assertSame( + [ + 'id_token_hint' => 'id-token', + 'client_id' => 'client-id', + ], + $this->sut()->buildEndSessionParameters( + idTokenHint: 'id-token', + clientId: 'client-id', + ), + ); + } + + public function testBuildEndSessionParametersWithAllValues(): void + { + $this->assertSame( + [ + 'id_token_hint' => 'id-token', + 'client_id' => 'client-id', + 'post_logout_redirect_uri' => 'https://rp.example.org/logged-out', + 'state' => 'logout-state', + 'logout_hint' => 'user@example.org', + 'ui_locales' => 'hr en', + ], + $this->sut()->buildEndSessionParameters( + idTokenHint: 'id-token', + clientId: 'client-id', + postLogoutRedirectUri: 'https://rp.example.org/logged-out', + state: 'logout-state', + logoutHint: 'user@example.org', + uiLocales: 'hr en', + ), + ); + } + + public function testValidateLogoutCallbackResponseVerifiesState(): void + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getQueryParams')->willReturn([ + 'state' => 'logout-state', + ]); + + $this->stateNonceDataHandlerMock->expects($this->once()) + ->method('verify') + ->with(StateNonce::LOGOUT_STATE_KEY, 'logout-state'); + + $this->sut()->validateLogoutCallbackResponse($request); + } + + public function testValidateLogoutCallbackResponseVerifiesStateFromParsedBody(): void + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getQueryParams')->willReturn([]); + $request->method('getParsedBody')->willReturn([ + 'state' => 'logout-state', + ]); + + $this->stateNonceDataHandlerMock->expects($this->once()) + ->method('verify') + ->with(StateNonce::LOGOUT_STATE_KEY, 'logout-state'); + + $this->sut()->validateLogoutCallbackResponse($request); + } + + public function testValidateLogoutCallbackResponseThrowsOnMissingState(): void + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getQueryParams')->willReturn([]); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Not all required parameters were provided (state).'); + + $this->sut()->validateLogoutCallbackResponse($request); + } + + public function testValidateLogoutCallbackResponseSkipsVerificationWithoutState(): void + { + $request = $this->createStub(ServerRequestInterface::class); + + $this->stateNonceDataHandlerMock->expects($this->never())->method('verify'); + + $this->sut()->validateLogoutCallbackResponse($request, false); + } } From 2e24ac02bd3ada4ba62ad0dcd0b5a9b2a2d45e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 4 Jul 2026 16:41:51 +0200 Subject: [PATCH 02/10] Store the login time client ID --- src/FederatedClient.php | 4 ++- src/PreRegisteredClient.php | 5 ++- src/Protocol/RequestDataHandler.php | 29 ++++++++++++++--- tests/Oidc/PreRegisteredClientTest.php | 31 +++++++++++++++++++ .../Oidc/Protocol/RequestDataHandlerTest.php | 11 +++++-- 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/FederatedClient.php b/src/FederatedClient.php index 9ed1c6e..0a3cd7d 100644 --- a/src/FederatedClient.php +++ b/src/FederatedClient.php @@ -981,7 +981,9 @@ public function logout( $parameters = $this->requestDataHandler->buildEndSessionParameters( idTokenHint: $this->requestDataHandler->getLoginIdToken(), - clientId: $this->entityConfig->getEntityId(), + // Prefer the client ID the login was performed with, so it + // matches the 'id_token_hint'. + clientId: $this->requestDataHandler->getLoginClientId() ?? $this->entityConfig->getEntityId(), postLogoutRedirectUri: $postLogoutRedirectUri, state: $this->requestDataHandler->getLogoutState(), logoutHint: $logoutHint, diff --git a/src/PreRegisteredClient.php b/src/PreRegisteredClient.php index f9519cd..5875a5a 100644 --- a/src/PreRegisteredClient.php +++ b/src/PreRegisteredClient.php @@ -405,7 +405,10 @@ public function logout( $parameters = $this->requestDataHandler->buildEndSessionParameters( idTokenHint: $this->requestDataHandler->getLoginIdToken(), - clientId: $this->clientId, + // Prefer the client ID the login was performed with, so it + // matches the 'id_token_hint' even if the client registration + // changed in the meantime (dynamically registered clients). + clientId: $this->requestDataHandler->getLoginClientId() ?? $this->clientId, postLogoutRedirectUri: $postLogoutRedirectUri, state: $this->useState ? $this->requestDataHandler->getLogoutState() : null, logoutHint: $logoutHint, diff --git a/src/Protocol/RequestDataHandler.php b/src/Protocol/RequestDataHandler.php index d8f4f80..278fd1e 100644 --- a/src/Protocol/RequestDataHandler.php +++ b/src/Protocol/RequestDataHandler.php @@ -165,6 +165,7 @@ public function getUserData( $this->storeLoginData( $tokenData[ParamsEnum::IdToken->value], $opEndSessionEndpoint, + $clientId, ); return $claims; @@ -946,16 +947,22 @@ public function getClientRedirectUriForState(string $state): string * Persist login data needed for logout in the session store: the raw ID * token (used as 'id_token_hint' in RP-Initiated Logout), its 'iss', * 'sub' and 'sid' claims (used to correlate OIDC Back-Channel Logout - * requests with this login), and the OP's end session endpoint (so - * logout can be performed even when OP metadata is no longer at hand, - * e.g. for OPs resolved per authorization flow). + * requests with this login), the OP's end session endpoint (so logout + * can be performed even when OP metadata is no longer at hand, e.g. for + * OPs resolved per authorization flow), and the client ID the login was + * performed with (so the logout request 'client_id' matches the + * 'id_token_hint' even if the client registration changes in the + * meantime, e.g. for dynamically registered clients). * * Claim extraction is best-effort: the ID token was already validated * during login, so an extraction error is only logged and the raw ID * token is stored anyway. */ - public function storeLoginData(?string $idToken, ?string $opEndSessionEndpoint = null): void - { + public function storeLoginData( + ?string $idToken, + ?string $opEndSessionEndpoint = null, + ?string $clientId = null, + ): void { $claims = []; if (is_string($idToken)) { @@ -974,6 +981,7 @@ public function storeLoginData(?string $idToken, ?string $opEndSessionEndpoint = ClaimsEnum::Sub->value => is_string($sub = $claims[ClaimsEnum::Sub->value] ?? null) ? $sub : null, ClaimsEnum::Sid->value => is_string($sid = $claims[ClaimsEnum::Sid->value] ?? null) ? $sid : null, ClaimsEnum::EndSessionEndpoint->value => $opEndSessionEndpoint, + ParamsEnum::ClientId->value => $clientId, ]); } @@ -1033,6 +1041,17 @@ public function getLoginEndSessionEndpoint(): ?string return $this->getLoginDataStringValue(ClaimsEnum::EndSessionEndpoint->value); } + /** + * The client ID the login was performed with (the one the ID token was + * issued to). Used as the 'client_id' logout request parameter, so it + * matches the 'id_token_hint' even if the client registration changes + * between login and logout. + */ + public function getLoginClientId(): ?string + { + return $this->getLoginDataStringValue(ParamsEnum::ClientId->value); + } + /** * Remove persisted login data from the session store (local logout). */ diff --git a/tests/Oidc/PreRegisteredClientTest.php b/tests/Oidc/PreRegisteredClientTest.php index e84b684..19cbc3b 100644 --- a/tests/Oidc/PreRegisteredClientTest.php +++ b/tests/Oidc/PreRegisteredClientTest.php @@ -637,6 +637,37 @@ public function testLogoutFormPostWithResponse(): void $this->assertSame($response, $result); } + public function testLogoutPrefersLoginTimeClientId(): void + { + $this->metadataMock->expects($this->exactly(1))->method('get')->willReturnMap([ + ['end_session_endpoint', 'https://op.example.org/end-session'], + ]); + + // The client registration changed between login and logout, so the + // 'client_id' logout parameter must match the one the stored ID + // token (id_token_hint) was issued to. + $this->requestDataHandlerMock->method('getLoginClientId')->willReturn('login-time-client-id'); + $this->requestDataHandlerMock->method('getLoginIdToken')->willReturn('id-token'); + $this->requestDataHandlerMock->method('getLogoutState')->willReturn('logout-state'); + $this->requestDataHandlerMock->expects($this->once()) + ->method('buildEndSessionParameters') + ->with( + 'id-token', + 'login-time-client-id', + null, + 'logout-state', + null, + null, + ) + ->willReturn([]); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->method('withHeader')->willReturn($response); + + $result = $this->sut()->logout(response: $response); + $this->assertSame($response, $result); + } + public function testLogoutWithoutStateOmitsLogoutState(): void { $this->metadataMock->expects($this->exactly(1))->method('get')->willReturnMap([ diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index 8c64e3a..24d25b7 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -1389,9 +1389,10 @@ public function testStoreLoginDataStoresIdTokenAndClaims(): void 'sub' => 'user-1', 'sid' => 'op-session-1', 'end_session_endpoint' => 'https://op.example.org/end-session', + 'client_id' => 'client-id', ]); - $this->sut()->storeLoginData('id-token', 'https://op.example.org/end-session'); + $this->sut()->storeLoginData('id-token', 'https://op.example.org/end-session', 'client-id'); } public function testStoreLoginDataStoresRawIdTokenOnClaimExtractionError(): void @@ -1410,6 +1411,7 @@ public function testStoreLoginDataStoresRawIdTokenOnClaimExtractionError(): void 'sub' => null, 'sid' => null, 'end_session_endpoint' => null, + 'client_id' => null, ]); $this->sut()->storeLoginData('id-token'); @@ -1427,9 +1429,10 @@ public function testStoreLoginDataWithoutIdToken(): void 'sub' => null, 'sid' => null, 'end_session_endpoint' => 'https://op.example.org/end-session', + 'client_id' => 'client-id', ]); - $this->sut()->storeLoginData(null, 'https://op.example.org/end-session'); + $this->sut()->storeLoginData(null, 'https://op.example.org/end-session', 'client-id'); } public function testLoginDataGetters(): void @@ -1442,6 +1445,7 @@ public function testLoginDataGetters(): void 'sub' => 'user-1', 'sid' => 'op-session-1', 'end_session_endpoint' => 'https://op.example.org/end-session', + 'client_id' => 'client-id', ]); $sut = $this->sut(); @@ -1451,6 +1455,7 @@ public function testLoginDataGetters(): void $this->assertSame('user-1', $sut->getLoginSubject()); $this->assertSame('op-session-1', $sut->getLoginSessionId()); $this->assertSame('https://op.example.org/end-session', $sut->getLoginEndSessionEndpoint()); + $this->assertSame('client-id', $sut->getLoginClientId()); } public function testLoginDataGettersReturnNullWhenNoLoginData(): void @@ -1465,6 +1470,7 @@ public function testLoginDataGettersReturnNullWhenNoLoginData(): void $this->assertNull($sut->getLoginSubject()); $this->assertNull($sut->getLoginSessionId()); $this->assertNull($sut->getLoginEndSessionEndpoint()); + $this->assertNull($sut->getLoginClientId()); } public function testClearLoginData(): void @@ -1538,6 +1544,7 @@ public function testGetUserDataStoresLoginData(): void 'sub' => 'sub1', 'sid' => 'op-session-1', 'end_session_endpoint' => 'https://op.example.org/end-session', + 'client_id' => 'client-id', ]); $this->sut()->getUserData( From c7b264e24fc7e42cef285f2e518d13da5f1c3ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sat, 4 Jul 2026 16:49:17 +0200 Subject: [PATCH 03/10] Warn about missing id_token_hint --- docs/2-Pre-Registered-Client.md | 28 ++++++++++++++++++++++---- docs/3-Federated-Client.md | 8 ++++++-- src/FederatedClient.php | 22 ++++++++++++++++++-- src/PreRegisteredClient.php | 23 +++++++++++++++++++-- tests/Oidc/FederatedClientTest.php | 19 +++++++++++++++++ tests/Oidc/PreRegisteredClientTest.php | 24 ++++++++++++++++++++++ 6 files changed, 114 insertions(+), 10 deletions(-) diff --git a/docs/2-Pre-Registered-Client.md b/docs/2-Pre-Registered-Client.md index b9ece4c..7483d02 100644 --- a/docs/2-Pre-Registered-Client.md +++ b/docs/2-Pre-Registered-Client.md @@ -216,9 +216,21 @@ After a successful login (`getUserData()`), the client persists the raw ID token and related login data in the session store. On `logout()`, the client removes that login data (local logout) and delivers a logout request to the OP's end session endpoint, carrying the ID token as `id_token_hint`, the -`client_id`, and a `state` parameter (if state check is enabled). Note that -destroying the application session itself (for example, `session_destroy()`) -remains the application's responsibility. +`client_id`, and a `state` parameter (if state check is enabled). + +Destroying the application session itself (for example, `session_destroy()`) +remains the application's responsibility - but note that with the default +`PhpSessionStore`, the persisted login data lives in the same PHP session as +your application data. **Do not destroy the PHP session before calling +`logout()`** - the ID token would be gone, and the logout request would be +sent without `id_token_hint` (a weaker request which the OP may refuse or +answer with a user confirmation prompt; the client logs a warning in that +case). Instead, remove your own application data from the session before +calling `logout()` (the client removes its own login data itself), and +destroy the session completely on the post logout redirect page. +Alternatively, use the PSR-7 `response` variant, in which case you can +destroy the session after `logout()` returns and before emitting the +response. ```php use Cicnavi\Oidc\PreRegisteredClient; @@ -226,7 +238,11 @@ use Cicnavi\Oidc\PreRegisteredClient; // File: logout.php try { - // Destroy your own application session as appropriate, then: + // Log out the user locally, but do not destroy the PHP session yet, + // since by default it also holds the ID token needed for the logout + // request: + unset($_SESSION['user']); + $oidcClient->logout( // Optional. Must be registered on the OP as one of the client's // 'post_logout_redirect_uris': @@ -249,6 +265,10 @@ use Cicnavi\Oidc\PreRegisteredClient; // File: logged-out.php try { $oidcClient->validateLogoutCallback(); + + // Now the session can be destroyed completely. + session_destroy(); + // Show a "logged out" page... } catch (\Throwable $exception) { // In a real app log the error and show an error message. diff --git a/docs/3-Federated-Client.md b/docs/3-Federated-Client.md index 408b63a..1e5bbcb 100644 --- a/docs/3-Federated-Client.md +++ b/docs/3-Federated-Client.md @@ -154,12 +154,16 @@ The client removes the persisted login data (local logout) and delivers a logout request to the OP's end session endpoint, carrying the ID token as `id_token_hint`, the RP entity ID as `client_id`, and a `state` parameter. Note that destroying the application session itself remains the -application's responsibility. +application's responsibility - but do not destroy the PHP session before +calling `logout()`, since with the default `PhpSessionStore` it also holds +the persisted login data (end session endpoint, ID token). See the note in +the [Pre-Registered Client documentation](2-Pre-Registered-Client.md#rp-initiated-logout) +on session handling around logout. ```php /** @var \Cicnavi\Oidc\FederatedClient $client */ -// Destroy your own application session as appropriate, then: +// Log out the user locally (but do not destroy the PHP session yet), then: $client->logout( // Optional. Must be registered as one of this RP's // 'post_logout_redirect_uris' metadata values (which can be provided diff --git a/src/FederatedClient.php b/src/FederatedClient.php index 0a3cd7d..dd72efc 100644 --- a/src/FederatedClient.php +++ b/src/FederatedClient.php @@ -942,7 +942,14 @@ public function getUserData(?ServerRequestInterface $request = null): array * token received at login as 'id_token_hint'. * * Note that this does not destroy the application session itself - the - * application should do that as part of its own logout handling. + * application should do that as part of its own logout handling. However, + * with the default PhpSessionStore the persisted login data lives in the + * same PHP session as the application data, so do not destroy the PHP + * session before calling this method - otherwise the login data is gone + * and logout is not possible (no end session endpoint) or is sent without + * 'id_token_hint' (a warning is logged in that case). Destroy the session + * on the post logout redirect page instead, or - when using the $response + * variant - after this method returns. * * @param ?string $postLogoutRedirectUri URI to which the OP should * redirect the user agent after logout. Must be registered as one of @@ -979,8 +986,19 @@ public function logout( throw new OidcClientException($error); } + $idTokenHint = $this->requestDataHandler->getLoginIdToken(); + + if ($idTokenHint === null) { + $this->logger?->warning( + 'No ID token found in persisted login data, sending RP-Initiated Logout request without ' . + '"id_token_hint". The OpenID Provider may refuse the request or prompt the user for ' . + 'confirmation. If the application session was destroyed before calling logout(), destroy ' . + 'it after the logout request is prepared instead (see logout() documentation).', + ); + } + $parameters = $this->requestDataHandler->buildEndSessionParameters( - idTokenHint: $this->requestDataHandler->getLoginIdToken(), + idTokenHint: $idTokenHint, // Prefer the client ID the login was performed with, so it // matches the 'id_token_hint'. clientId: $this->requestDataHandler->getLoginClientId() ?? $this->entityConfig->getEntityId(), diff --git a/src/PreRegisteredClient.php b/src/PreRegisteredClient.php index 5875a5a..5aca48f 100644 --- a/src/PreRegisteredClient.php +++ b/src/PreRegisteredClient.php @@ -368,7 +368,15 @@ public function getUserData(?ServerRequestInterface $request = null): array * 'id_token_hint'. * * Note that this does not destroy the application session itself - the - * application should do that as part of its own logout handling. + * application should do that as part of its own logout handling. However, + * with the default PhpSessionStore the persisted login data lives in the + * same PHP session as the application data, so do not destroy the PHP + * session before calling this method - otherwise the ID token is gone and + * the logout request is sent without 'id_token_hint' (a weaker request + * which the OP may refuse or answer with a user confirmation prompt; a + * warning is logged in that case). Destroy the session on the post logout + * redirect page instead, or - when using the $response variant - after + * this method returns. * * @param ?string $postLogoutRedirectUri URI to which the OP should * redirect the user agent after logout. Must be registered on the OP as @@ -403,8 +411,19 @@ public function logout( ); } + $idTokenHint = $this->requestDataHandler->getLoginIdToken(); + + if ($idTokenHint === null) { + $this->logger?->warning( + 'No ID token found in persisted login data, sending RP-Initiated Logout request without ' . + '"id_token_hint". The OpenID Provider may refuse the request or prompt the user for ' . + 'confirmation. If the application session was destroyed before calling logout(), destroy ' . + 'it after the logout request is prepared instead (see logout() documentation).', + ); + } + $parameters = $this->requestDataHandler->buildEndSessionParameters( - idTokenHint: $this->requestDataHandler->getLoginIdToken(), + idTokenHint: $idTokenHint, // Prefer the client ID the login was performed with, so it // matches the 'id_token_hint' even if the client registration // changed in the meantime (dynamically registered clients). diff --git a/tests/Oidc/FederatedClientTest.php b/tests/Oidc/FederatedClientTest.php index fccad4a..a9b606d 100644 --- a/tests/Oidc/FederatedClientTest.php +++ b/tests/Oidc/FederatedClientTest.php @@ -670,6 +670,25 @@ public function testLogoutRedirectWithResponse(): void $this->assertSame($responseMock, $this->sut()->logout(response: $responseMock)); } + public function testLogoutWarnsWhenNoIdTokenHintAvailable(): void + { + $this->entityConfigMock->method('getEntityId')->willReturn('https://rp.example.org'); + $this->requestDataHandlerMock->method('getLoginEndSessionEndpoint') + ->willReturn('https://op.example.org/end-session'); + $this->requestDataHandlerMock->method('getLoginIdToken')->willReturn(null); + $this->requestDataHandlerMock->method('getLogoutState')->willReturn('logout-state'); + $this->requestDataHandlerMock->method('buildEndSessionParameters')->willReturn([]); + + $this->loggerMock->expects($this->once()) + ->method('warning') + ->with($this->stringContains('id_token_hint')); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->method('withHeader')->willReturn($responseMock); + + $this->assertSame($responseMock, $this->sut()->logout(response: $responseMock)); + } + public function testLogoutThrowsWhenEndSessionEndpointNotAvailable(): void { $this->requestDataHandlerMock->method('getLoginEndSessionEndpoint')->willReturn(null); diff --git a/tests/Oidc/PreRegisteredClientTest.php b/tests/Oidc/PreRegisteredClientTest.php index 19cbc3b..41cec8a 100644 --- a/tests/Oidc/PreRegisteredClientTest.php +++ b/tests/Oidc/PreRegisteredClientTest.php @@ -668,6 +668,30 @@ public function testLogoutPrefersLoginTimeClientId(): void $this->assertSame($response, $result); } + public function testLogoutWarnsWhenNoIdTokenHintAvailable(): void + { + $this->metadataMock->expects($this->exactly(1))->method('get')->willReturnMap([ + ['end_session_endpoint', 'https://op.example.org/end-session'], + ]); + + // No login data available (e.g., the application session was + // destroyed before calling logout()). + $this->requestDataHandlerMock->method('getLoginIdToken')->willReturn(null); + $this->requestDataHandlerMock->method('getLogoutState')->willReturn('logout-state'); + $this->requestDataHandlerMock->method('buildEndSessionParameters')->willReturn([]); + + $loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); + $loggerMock->expects($this->once()) + ->method('warning') + ->with($this->stringContains('id_token_hint')); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->method('withHeader')->willReturn($response); + + $result = $this->sut(logger: $loggerMock)->logout(response: $response); + $this->assertSame($response, $result); + } + public function testLogoutWithoutStateOmitsLogoutState(): void { $this->metadataMock->expects($this->exactly(1))->method('get')->willReturnMap([ From 17f9951c136552089a878ae728bf15369de5b58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 5 Jul 2026 09:19:01 +0200 Subject: [PATCH 04/10] Normalize whitespace --- .gitattributes | 3 + docs/1-Index.md | 132 ++-- docs/2-Pre-Registered-Client.md | 686 +++++++++--------- docs/3-Federated-Client.md | 548 +++++++------- .../FederationDiscoveryController.php | 270 +++---- 5 files changed, 821 insertions(+), 818 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7ccbc02 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Normalize line endings: store all text files with LF in the repository +# and use LF in working trees, regardless of platform. +* text=auto eol=lf diff --git a/docs/1-Index.md b/docs/1-Index.md index a69de90..bcaa7c3 100644 --- a/docs/1-Index.md +++ b/docs/1-Index.md @@ -1,66 +1,66 @@ -# OIDC Client PHP - -## Prerequisites - -PHP environment: -* Check `composer.json` for environment requirements. -* ODIC client uses PHP session by default to handle `state`, `nonce` and -`code_verifier` parameters storage and validation. If the session is not -already started, the OIDC client will try to start it using session config -from `php.ini`. - -OpenID Provider must support: -* Authorization Code Flow -* OIDC Discovery URL (`.well-known` URL with OP metadata) -* JWKS URI providing JWK key(s) - -## Installation - -OIDC Client is available as a Composer package. In your project you can run: - -```shell script -composer require cicnavi/oidc-client-php -``` - -## Client Usage - -There are three ways to instantiate an OIDC client: -* Pre-registered Client (`Cicnavi\Oidc\PreRegisteredClient`) - can be used if -the client is already registered with the OpenID Provider. -* Federated Client (`Cicnavi\Oidc\FederatedClient`) - can be used in federated -environments (as per OpenID Federation specification). This client type -currently supports Automatic Client Registration flow using Request Object -passed by value. -* Dynamically Registered Client (`Cicnavi\Oidc\DynamicallyRegisteredClient`) - -can be used if the OpenID Provider supports OpenID Connect Dynamic Client -Registration 1.0. The client registers itself with the OpenID Provider and -uses the issued client credentials. - -Check the dedicated sections below for more details about each client type: -* [Pre-registered Client](2-Pre-Registered-Client.md) -* [Federated Client](3-Federated-Client.md) -* [Dynamically Registered Client](4-Dynamically-Registered-Client.md) -* [Conformance Testing](5-Conformance-Testing.md) - - -## Note on SameSite Cookie Attribute - -[SameSite Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite) -attribute plays an important role in Single Sign-On (SSO) environments -because it determines how cookies are delivered in third party contexts. -During OIDC authorization code flow (the authentication flow this OIDC client -uses), a series of HTTP redirects between RP and OP is performed. - -By default, the authorization code will be delivered to the RP using HTTP -Redirect, meaning that the User Agent will do a GET request to the RP callback. -This means that the SameSite Cookie attribute can be set to `Lax` or `None`, -but not `Strict` (if the value is `None`, the attribute `Secure` must also -be set). - -## Run tests - -All tests are available as Composer scripts, so you can run them like this: - -```bash -$ composer run-script test -``` +# OIDC Client PHP + +## Prerequisites + +PHP environment: +* Check `composer.json` for environment requirements. +* ODIC client uses PHP session by default to handle `state`, `nonce` and +`code_verifier` parameters storage and validation. If the session is not +already started, the OIDC client will try to start it using session config +from `php.ini`. + +OpenID Provider must support: +* Authorization Code Flow +* OIDC Discovery URL (`.well-known` URL with OP metadata) +* JWKS URI providing JWK key(s) + +## Installation + +OIDC Client is available as a Composer package. In your project you can run: + +```shell script +composer require cicnavi/oidc-client-php +``` + +## Client Usage + +There are three ways to instantiate an OIDC client: +* Pre-registered Client (`Cicnavi\Oidc\PreRegisteredClient`) - can be used if +the client is already registered with the OpenID Provider. +* Federated Client (`Cicnavi\Oidc\FederatedClient`) - can be used in federated +environments (as per OpenID Federation specification). This client type +currently supports Automatic Client Registration flow using Request Object +passed by value. +* Dynamically Registered Client (`Cicnavi\Oidc\DynamicallyRegisteredClient`) - +can be used if the OpenID Provider supports OpenID Connect Dynamic Client +Registration 1.0. The client registers itself with the OpenID Provider and +uses the issued client credentials. + +Check the dedicated sections below for more details about each client type: +* [Pre-registered Client](2-Pre-Registered-Client.md) +* [Federated Client](3-Federated-Client.md) +* [Dynamically Registered Client](4-Dynamically-Registered-Client.md) +* [Conformance Testing](5-Conformance-Testing.md) + + +## Note on SameSite Cookie Attribute + +[SameSite Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite) +attribute plays an important role in Single Sign-On (SSO) environments +because it determines how cookies are delivered in third party contexts. +During OIDC authorization code flow (the authentication flow this OIDC client +uses), a series of HTTP redirects between RP and OP is performed. + +By default, the authorization code will be delivered to the RP using HTTP +Redirect, meaning that the User Agent will do a GET request to the RP callback. +This means that the SameSite Cookie attribute can be set to `Lax` or `None`, +but not `Strict` (if the value is `None`, the attribute `Secure` must also +be set). + +## Run tests + +All tests are available as Composer scripts, so you can run them like this: + +```bash +$ composer run-script test +``` diff --git a/docs/2-Pre-Registered-Client.md b/docs/2-Pre-Registered-Client.md index 7483d02..56300d3 100644 --- a/docs/2-Pre-Registered-Client.md +++ b/docs/2-Pre-Registered-Client.md @@ -1,343 +1,343 @@ -# Pre-Registered Client - -Pre-Registered Client can be used if the client is already registered with the -OpenID Provider, meaning you already have the client ID and client secret. - -To instantiate a client, provide configuration parameters to the -`\Cicnavi\Oidc\PreRegisteredClient` constructor. Here's a basic example -with required parameters: - -```php -use Cicnavi\Oidc\PreRegisteredClient; - -// Create a client with required parameters -$oidcClient = new PreRegisteredClient( - opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', - clientId: 'some-client-id', - clientSecret: 'some-client-secret', - redirectUri: 'https://your-example.org/callback', - scope: 'openid profile' -); -``` - -Make sure to include the `openid` scope to use ID token for user claims -extraction. Other scopes are optional (refer to the documentation for -your OpenID Provider). - -### Optional Parameters - -You can also customize the client behavior with optional parameters: - -```php -use SimpleSAML\OpenID\Codebooks\PkceCodeChallengeMethodEnum; -use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; -use Cicnavi\Oidc\PreRegisteredClient; -use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; -use Cicnavi\Oidc\CodeBooks\ParModeEnum; - -$oidcClient = new PreRegisteredClient( - // Required parameters - opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', - clientId: 'some-client-id', - clientSecret: 'some-client-secret', - redirectUri: 'https://your-example.org/callback', - scope: 'openid profile', - - // Optional parameters with default values - usePkce: true, // Determines if PKCE should be used in authorization flow. True by default. - pkceCodeChallengeMethod: PkceCodeChallengeMethodEnum::S256, // If PKCE is used, which Code Challenge Method should be used. - timestampValidationLeeway: new \DateInterval('PT1M'), // Leeway used for timestamp (exp, iat, nbf...) validation. - useState: true, // Enable / disable state check - useNonce: true, // Enable / disable nonce check - fetchUserinfoClaims: true, // Fetch claims from the userinfo endpoint - maxCacheDuration: new \DateInterval('PT6H'), // Cache max TTL - logger: null, // \Psr\Log\LoggerInterface instance - defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::FormPost, // Determines the default authorization request method. - responseMode: null, // Determines the OIDC response mode (e.g., ResponseModesEnum::Query or ResponseModesEnum::FormPost. Fragment is not supported). Null by default. - parMode: ParModeEnum::Auto, // Pushed Authorization Requests (RFC 9126) mode. See below. -); -``` - -### Pushed Authorization Requests (PAR, RFC 9126) - -With PAR, the authorization request parameters are first POSTed directly to the -OP's `pushed_authorization_request_endpoint` (a back-channel, client-authenticated -call). The OP returns a short-lived, one-time `request_uri`, and the browser is -then sent to the authorization endpoint carrying only `client_id` and -`request_uri`. Client authentication uses the same `client_secret_basic` -credentials as the token endpoint, and PKCE / state / nonce are unchanged — only -the *delivery* of the request differs. PAR is orthogonal to -`AuthorizationRequestMethodEnum` (Query / FormPost). - -The `parMode` option (`ParModeEnum`) controls when PAR is used: - -- `ParModeEnum::Off` — never use PAR. Note: if the OP requires PAR - (`require_pushed_authorization_requests = true`), it will reject the request. -- `ParModeEnum::Auto` (default) — use PAR only when the OP requires it. Otherwise - the authorization request is delivered as usual. -- `ParModeEnum::Required` — always use PAR; an exception is thrown if the OP does - not advertise a `pushed_authorization_request_endpoint`. - -The mode can also be overridden per call: `$oidcClient->authorize(parMode: ParModeEnum::Required)`. - -## Client usage - -To initiate authorization (Authorization Code Flow), that is, to initiate a -login process, you can use the `authorize()` method: - -```php -use Cicnavi\Oidc\PreRegisteredClient; -use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; -use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; -/** @var PreRegisteredClient $oidcClient */ - -// File: authorize.php -try { - // You can also explicitly pass custom authorization request method and response mode: - $oidcClient->authorize( - authorizationRequestMethod: AuthorizationRequestMethodEnum::Query, - responseMode: ResponseModesEnum::Query - ); -} catch (\Throwable $exception) { - // In real app log the error, redirect user and show error message. - throw $exception; -} -``` -This will initiate a browser request (GET or POST, depending on -`AuthorizationRequestMethodEnum`) to the authorization server, -where the user will log in. If the login is successful, the authorization -server will initiate a browser redirection to the `redirect_uri` -which was registered with the client (this is your callback). - -On the callback URI, you'll receive authorization `code` and `state` -(if state check is enabled) as GET (for `query` response mode) or POST -(for `form_post` response mode) parameters. The `getUserData()` method -automatically handles both types of callbacks. -This method will validate `state` (if `state` check is enabled) and send -an HTTP request to token endpoint using the provided authorization `code` -to retrieve tokens (access and ID token). After that it will try to -extract claims from ID token (if it was returned, that is if the `openid` -scope was used in client configuration), and will fetch user data from -`userinfo` endpoint using access token for authentication. - -```php -use Cicnavi\Oidc\PreRegisteredClient; -/** @var PreRegisteredClient $oidcClient */ - -// File: callback.php -try { - $userData = $oidcClient->getUserData(); - - // Log in the user locally, for example: - if (isset($userData['preferred_username'])) { - $_SESSION['user'] = $userData['preferred_username']; - // In the real app redirect to another page, show a success message... - } else { - // In the real app redirect to another page, show an error message... - } - - // This part is for demo purposes, so we can see returned user data. - $userDataString = var_export($userData, true); - - $content = << -
-        {$userDataString} 
-
-
- Back to start page - EOT; - - require __DIR__ . '/../views/page.php'; -} catch (\Throwable $exception) { - // In a real app log the error, redirect the user and show an error message. - throw $exception; -} -``` -The returned user data will be in the form of an array, for example: -```php -array ( - 'iss' => 'http://example.org', - 'aud' => 'f7f0a46fbd8469a6bb', - 'jti' => 'bc59a823b69945cc8e3731cedc536ed44d3', - 'nbf' => 1593006799, - 'exp' => 1595598799, - 'sub' => 'da4294fb4af275', - 'iat' => 1593006799, - 'family_name' => 'John', - 'given_name' => 'Doe', - 'nickname' => 'jdoe', - 'preferred_username' => 'jdoe@example.org', - 'name' => 'John Doe', - 'email' => 'john.doe@example.org', - 'address' => 'Some organization, Example street 123, HR-10000 Zagreb, Croatia', - 'phone_number' => '123', - // ... -) -``` -Note that some OpenID providers (for example, AAI@EduHr Federation) will send -claims that have multiple values, for example: -``` -// ... -'hrEduPersonUniqueID' => - array ( - 0 => 'jdoe@example.org', - ), - 'uid' => - array ( - 0 => 'jdoe', - ), - 'cn' => - array ( - 0 => 'John Doe', - ), - 'sn' => - array ( - 0 => 'Doe', - ), - 'givenName' => - array ( - 0 => 'John', - ), - 'mail' => - array ( - 0 => 'john.doe@example.org', - 1 => 'jdoe@example.org', - ), -``` - -## RP-Initiated Logout - -If the OpenID Provider advertises an `end_session_endpoint` in its metadata, -you can use the `logout()` method to perform -[OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). - -After a successful login (`getUserData()`), the client persists the raw ID -token and related login data in the session store. On `logout()`, the client -removes that login data (local logout) and delivers a logout request to the -OP's end session endpoint, carrying the ID token as `id_token_hint`, the -`client_id`, and a `state` parameter (if state check is enabled). - -Destroying the application session itself (for example, `session_destroy()`) -remains the application's responsibility - but note that with the default -`PhpSessionStore`, the persisted login data lives in the same PHP session as -your application data. **Do not destroy the PHP session before calling -`logout()`** - the ID token would be gone, and the logout request would be -sent without `id_token_hint` (a weaker request which the OP may refuse or -answer with a user confirmation prompt; the client logs a warning in that -case). Instead, remove your own application data from the session before -calling `logout()` (the client removes its own login data itself), and -destroy the session completely on the post logout redirect page. -Alternatively, use the PSR-7 `response` variant, in which case you can -destroy the session after `logout()` returns and before emitting the -response. - -```php -use Cicnavi\Oidc\PreRegisteredClient; -/** @var PreRegisteredClient $oidcClient */ - -// File: logout.php -try { - // Log out the user locally, but do not destroy the PHP session yet, - // since by default it also holds the ID token needed for the logout - // request: - unset($_SESSION['user']); - - $oidcClient->logout( - // Optional. Must be registered on the OP as one of the client's - // 'post_logout_redirect_uris': - postLogoutRedirectUri: 'https://client.example.org/logged-out.php', - ); -} catch (\Throwable $exception) { - // In a real app log the error, redirect the user and show an error message. - throw $exception; -} -``` - -If a `post_logout_redirect_uri` was provided, the OP will redirect the user -back to it after logout, returning the `state` parameter. Validate it using -`validateLogoutCallback()`: - -```php -use Cicnavi\Oidc\PreRegisteredClient; -/** @var PreRegisteredClient $oidcClient */ - -// File: logged-out.php -try { - $oidcClient->validateLogoutCallback(); - - // Now the session can be destroyed completely. - session_destroy(); - - // Show a "logged out" page... -} catch (\Throwable $exception) { - // In a real app log the error and show an error message. - throw $exception; -} -``` - -The `logout()` method also accepts optional `logoutHint` and `uiLocales` -parameters, a `logoutRequestMethod` (HTTP GET redirect by default), and a -PSR-7 `response` instance which will be populated with proper headers and -returned (instead of performing an immediate redirect). - -The raw ID token received at login is also available using the -`getIdToken()` method (and related login data using `getLoginData()`), for -example, if you need to build a custom logout request yourself. - -## Note on Caching - -OIDC client uses caching to avoid sending HTTP requests to fetch OIDC -configuration content and JWKS content on every client usage. - -Default cache TTL (time-to-live) is set in configuration, so you can modify -it as needed. If you need to bust cache, use `reinitializeCache()` client -instance before making any authentication calls. - -```php -use Cicnavi\Oidc\PreRegisteredClient; - -// ... -$oidcClient = new PreRegisteredClient( - opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', - clientId: 'some-client-id', - clientSecret: 'some-client-secret', - redirectUri: 'https://your-example.org/callback', - scope: 'openid profile' -); -$oidcClient->reinitializeCache(); -// ... -``` - -By default, an OIDC client uses file-based caching. This means that it uses a -folder on your system to store files with cached data. For your convenience, -class `Cicnavi\Oidc\Cache\FileCache` is used to instantiate a Cache instance -which will store files in the default system `tmp` folder. -In the background, this class will use the `cicnavi/simple-file-cache-php` -package. If you want, you can use other caching techniques (memcached, redis...) -by installing the corresponding package which provides -[psr/simple-cache-implementation](https://packagist.org/providers/psr/simple-cache-implementation), and use it for OIDC client -instantiation. - -The example below demonstrates how to initialize the default `FileCache` -instance using a custom cache name and folder path (make sure the folder exists -and is writable by the web server). - -```php -use Cicnavi\Oidc\Cache\FileCache; -use Cicnavi\Oidc\PreRegisteredClient; -// ... other imports - -$storagePath = __DIR__ . '/../storage'; -$oidcCache = new FileCache($storagePath); - -// Create client instance with custom cache -$oidcClient = new PreRegisteredClient( - opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', - clientId: 'some-client-id', - clientSecret: 'some-client-secret', - redirectUri: 'https://your-example.org/callback', - scope: 'openid profile', - cache: $oidcCache // Pass a custom cache instance -); -``` +# Pre-Registered Client + +Pre-Registered Client can be used if the client is already registered with the +OpenID Provider, meaning you already have the client ID and client secret. + +To instantiate a client, provide configuration parameters to the +`\Cicnavi\Oidc\PreRegisteredClient` constructor. Here's a basic example +with required parameters: + +```php +use Cicnavi\Oidc\PreRegisteredClient; + +// Create a client with required parameters +$oidcClient = new PreRegisteredClient( + opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', + clientId: 'some-client-id', + clientSecret: 'some-client-secret', + redirectUri: 'https://your-example.org/callback', + scope: 'openid profile' +); +``` + +Make sure to include the `openid` scope to use ID token for user claims +extraction. Other scopes are optional (refer to the documentation for +your OpenID Provider). + +### Optional Parameters + +You can also customize the client behavior with optional parameters: + +```php +use SimpleSAML\OpenID\Codebooks\PkceCodeChallengeMethodEnum; +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; +use Cicnavi\Oidc\PreRegisteredClient; +use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; +use Cicnavi\Oidc\CodeBooks\ParModeEnum; + +$oidcClient = new PreRegisteredClient( + // Required parameters + opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', + clientId: 'some-client-id', + clientSecret: 'some-client-secret', + redirectUri: 'https://your-example.org/callback', + scope: 'openid profile', + + // Optional parameters with default values + usePkce: true, // Determines if PKCE should be used in authorization flow. True by default. + pkceCodeChallengeMethod: PkceCodeChallengeMethodEnum::S256, // If PKCE is used, which Code Challenge Method should be used. + timestampValidationLeeway: new \DateInterval('PT1M'), // Leeway used for timestamp (exp, iat, nbf...) validation. + useState: true, // Enable / disable state check + useNonce: true, // Enable / disable nonce check + fetchUserinfoClaims: true, // Fetch claims from the userinfo endpoint + maxCacheDuration: new \DateInterval('PT6H'), // Cache max TTL + logger: null, // \Psr\Log\LoggerInterface instance + defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::FormPost, // Determines the default authorization request method. + responseMode: null, // Determines the OIDC response mode (e.g., ResponseModesEnum::Query or ResponseModesEnum::FormPost. Fragment is not supported). Null by default. + parMode: ParModeEnum::Auto, // Pushed Authorization Requests (RFC 9126) mode. See below. +); +``` + +### Pushed Authorization Requests (PAR, RFC 9126) + +With PAR, the authorization request parameters are first POSTed directly to the +OP's `pushed_authorization_request_endpoint` (a back-channel, client-authenticated +call). The OP returns a short-lived, one-time `request_uri`, and the browser is +then sent to the authorization endpoint carrying only `client_id` and +`request_uri`. Client authentication uses the same `client_secret_basic` +credentials as the token endpoint, and PKCE / state / nonce are unchanged — only +the *delivery* of the request differs. PAR is orthogonal to +`AuthorizationRequestMethodEnum` (Query / FormPost). + +The `parMode` option (`ParModeEnum`) controls when PAR is used: + +- `ParModeEnum::Off` — never use PAR. Note: if the OP requires PAR + (`require_pushed_authorization_requests = true`), it will reject the request. +- `ParModeEnum::Auto` (default) — use PAR only when the OP requires it. Otherwise + the authorization request is delivered as usual. +- `ParModeEnum::Required` — always use PAR; an exception is thrown if the OP does + not advertise a `pushed_authorization_request_endpoint`. + +The mode can also be overridden per call: `$oidcClient->authorize(parMode: ParModeEnum::Required)`. + +## Client usage + +To initiate authorization (Authorization Code Flow), that is, to initiate a +login process, you can use the `authorize()` method: + +```php +use Cicnavi\Oidc\PreRegisteredClient; +use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; +/** @var PreRegisteredClient $oidcClient */ + +// File: authorize.php +try { + // You can also explicitly pass custom authorization request method and response mode: + $oidcClient->authorize( + authorizationRequestMethod: AuthorizationRequestMethodEnum::Query, + responseMode: ResponseModesEnum::Query + ); +} catch (\Throwable $exception) { + // In real app log the error, redirect user and show error message. + throw $exception; +} +``` +This will initiate a browser request (GET or POST, depending on +`AuthorizationRequestMethodEnum`) to the authorization server, +where the user will log in. If the login is successful, the authorization +server will initiate a browser redirection to the `redirect_uri` +which was registered with the client (this is your callback). + +On the callback URI, you'll receive authorization `code` and `state` +(if state check is enabled) as GET (for `query` response mode) or POST +(for `form_post` response mode) parameters. The `getUserData()` method +automatically handles both types of callbacks. +This method will validate `state` (if `state` check is enabled) and send +an HTTP request to token endpoint using the provided authorization `code` +to retrieve tokens (access and ID token). After that it will try to +extract claims from ID token (if it was returned, that is if the `openid` +scope was used in client configuration), and will fetch user data from +`userinfo` endpoint using access token for authentication. + +```php +use Cicnavi\Oidc\PreRegisteredClient; +/** @var PreRegisteredClient $oidcClient */ + +// File: callback.php +try { + $userData = $oidcClient->getUserData(); + + // Log in the user locally, for example: + if (isset($userData['preferred_username'])) { + $_SESSION['user'] = $userData['preferred_username']; + // In the real app redirect to another page, show a success message... + } else { + // In the real app redirect to another page, show an error message... + } + + // This part is for demo purposes, so we can see returned user data. + $userDataString = var_export($userData, true); + + $content = << +
+        {$userDataString} 
+
+
+ Back to start page + EOT; + + require __DIR__ . '/../views/page.php'; +} catch (\Throwable $exception) { + // In a real app log the error, redirect the user and show an error message. + throw $exception; +} +``` +The returned user data will be in the form of an array, for example: +```php +array ( + 'iss' => 'http://example.org', + 'aud' => 'f7f0a46fbd8469a6bb', + 'jti' => 'bc59a823b69945cc8e3731cedc536ed44d3', + 'nbf' => 1593006799, + 'exp' => 1595598799, + 'sub' => 'da4294fb4af275', + 'iat' => 1593006799, + 'family_name' => 'John', + 'given_name' => 'Doe', + 'nickname' => 'jdoe', + 'preferred_username' => 'jdoe@example.org', + 'name' => 'John Doe', + 'email' => 'john.doe@example.org', + 'address' => 'Some organization, Example street 123, HR-10000 Zagreb, Croatia', + 'phone_number' => '123', + // ... +) +``` +Note that some OpenID providers (for example, AAI@EduHr Federation) will send +claims that have multiple values, for example: +``` +// ... +'hrEduPersonUniqueID' => + array ( + 0 => 'jdoe@example.org', + ), + 'uid' => + array ( + 0 => 'jdoe', + ), + 'cn' => + array ( + 0 => 'John Doe', + ), + 'sn' => + array ( + 0 => 'Doe', + ), + 'givenName' => + array ( + 0 => 'John', + ), + 'mail' => + array ( + 0 => 'john.doe@example.org', + 1 => 'jdoe@example.org', + ), +``` + +## RP-Initiated Logout + +If the OpenID Provider advertises an `end_session_endpoint` in its metadata, +you can use the `logout()` method to perform +[OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). + +After a successful login (`getUserData()`), the client persists the raw ID +token and related login data in the session store. On `logout()`, the client +removes that login data (local logout) and delivers a logout request to the +OP's end session endpoint, carrying the ID token as `id_token_hint`, the +`client_id`, and a `state` parameter (if state check is enabled). + +Destroying the application session itself (for example, `session_destroy()`) +remains the application's responsibility - but note that with the default +`PhpSessionStore`, the persisted login data lives in the same PHP session as +your application data. **Do not destroy the PHP session before calling +`logout()`** - the ID token would be gone, and the logout request would be +sent without `id_token_hint` (a weaker request which the OP may refuse or +answer with a user confirmation prompt; the client logs a warning in that +case). Instead, remove your own application data from the session before +calling `logout()` (the client removes its own login data itself), and +destroy the session completely on the post logout redirect page. +Alternatively, use the PSR-7 `response` variant, in which case you can +destroy the session after `logout()` returns and before emitting the +response. + +```php +use Cicnavi\Oidc\PreRegisteredClient; +/** @var PreRegisteredClient $oidcClient */ + +// File: logout.php +try { + // Log out the user locally, but do not destroy the PHP session yet, + // since by default it also holds the ID token needed for the logout + // request: + unset($_SESSION['user']); + + $oidcClient->logout( + // Optional. Must be registered on the OP as one of the client's + // 'post_logout_redirect_uris': + postLogoutRedirectUri: 'https://client.example.org/logged-out.php', + ); +} catch (\Throwable $exception) { + // In a real app log the error, redirect the user and show an error message. + throw $exception; +} +``` + +If a `post_logout_redirect_uri` was provided, the OP will redirect the user +back to it after logout, returning the `state` parameter. Validate it using +`validateLogoutCallback()`: + +```php +use Cicnavi\Oidc\PreRegisteredClient; +/** @var PreRegisteredClient $oidcClient */ + +// File: logged-out.php +try { + $oidcClient->validateLogoutCallback(); + + // Now the session can be destroyed completely. + session_destroy(); + + // Show a "logged out" page... +} catch (\Throwable $exception) { + // In a real app log the error and show an error message. + throw $exception; +} +``` + +The `logout()` method also accepts optional `logoutHint` and `uiLocales` +parameters, a `logoutRequestMethod` (HTTP GET redirect by default), and a +PSR-7 `response` instance which will be populated with proper headers and +returned (instead of performing an immediate redirect). + +The raw ID token received at login is also available using the +`getIdToken()` method (and related login data using `getLoginData()`), for +example, if you need to build a custom logout request yourself. + +## Note on Caching + +OIDC client uses caching to avoid sending HTTP requests to fetch OIDC +configuration content and JWKS content on every client usage. + +Default cache TTL (time-to-live) is set in configuration, so you can modify +it as needed. If you need to bust cache, use `reinitializeCache()` client +instance before making any authentication calls. + +```php +use Cicnavi\Oidc\PreRegisteredClient; + +// ... +$oidcClient = new PreRegisteredClient( + opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', + clientId: 'some-client-id', + clientSecret: 'some-client-secret', + redirectUri: 'https://your-example.org/callback', + scope: 'openid profile' +); +$oidcClient->reinitializeCache(); +// ... +``` + +By default, an OIDC client uses file-based caching. This means that it uses a +folder on your system to store files with cached data. For your convenience, +class `Cicnavi\Oidc\Cache\FileCache` is used to instantiate a Cache instance +which will store files in the default system `tmp` folder. +In the background, this class will use the `cicnavi/simple-file-cache-php` +package. If you want, you can use other caching techniques (memcached, redis...) +by installing the corresponding package which provides +[psr/simple-cache-implementation](https://packagist.org/providers/psr/simple-cache-implementation), and use it for OIDC client +instantiation. + +The example below demonstrates how to initialize the default `FileCache` +instance using a custom cache name and folder path (make sure the folder exists +and is writable by the web server). + +```php +use Cicnavi\Oidc\Cache\FileCache; +use Cicnavi\Oidc\PreRegisteredClient; +// ... other imports + +$storagePath = __DIR__ . '/../storage'; +$oidcCache = new FileCache($storagePath); + +// Create client instance with custom cache +$oidcClient = new PreRegisteredClient( + opConfigurationUrl: 'https://example.org/oidc/.well-known/openid-configuration', + clientId: 'some-client-id', + clientSecret: 'some-client-secret', + redirectUri: 'https://your-example.org/callback', + scope: 'openid profile', + cache: $oidcCache // Pass a custom cache instance +); +``` diff --git a/docs/3-Federated-Client.md b/docs/3-Federated-Client.md index 1e5bbcb..91cb009 100644 --- a/docs/3-Federated-Client.md +++ b/docs/3-Federated-Client.md @@ -1,275 +1,275 @@ -# Federated Client - -The `FederatedClient` class provides an implementation for an OpenID Connect -Relying Party (RP) that supports **Automatic Client Registration** as defined -in the [OpenID Federation 1.0](https://openid.net/specs/openid-federation-1_0.html) specification. - -## Features - -- **Trust Chain Resolution**: Automatically resolves and validates trust chains -from the OpenID Provider (OP) to a configured Trust Anchor. -- **Automatic Registration**: Dynamically registers the client at the OP during -the first authentication request. -- **Metadata Management**: Handles the generation of the RP's Entity -Configuration and metadata, including keys and trust marks. -- **Federation Discovery**: Supports discovering OPs and their metadata. -- **OIDC Flow**: Manages the authorization code flow, including PKCE, -state/nonce validation, and ID Token verification. -- **Caching**: Efficiently caches resolved trust chains and metadata to improve -performance. - -## Prerequisites - -### PKI - -The Federated Client requires at least two sets of cryptographic keys: -1. **Federation Keys**: Used to sign the RP's Entity Configuration. -2. **Connect Keys**: Used for OIDC protocol operations (e.g., signing Request -Objects or private_key_jwt authentication). - -Sample commands to generate RSA key pairs using OpenSSL: - -```bash -# Generate Federation keys -openssl genrsa -out keys/federation-sig.key 3072 -openssl rsa -in keys/federation-sig.key -pubout -out keys/federation-sig.pub - -# Generate Connect keys -openssl genrsa -out keys/connect-sig.key 3072 -openssl rsa -in keys/connect-sig.key -pubout -out keys/connect-sig.pub -``` - -## Configuration - -The client is configured using `EntityConfig` (for federation-related settings) -and `RelyingPartyConfig` (for OIDC-related settings). - -See the [Configuration Example](../examples/FederatedClient/federated-client-config-example.php) -for a detailed structure. - -### Key Configuration Parameters - -- `entityId`: The unique identifier for your RP (must be a URL). -- `trustAnchorBag`: A collection of trusted root entities (Trust Anchors). -- `authorityHintBag`: A list of immediate superior entities in the federation. -- `redirectUriBag`: Authorized callback URIs for your application. - -## Usage - -### 1. Instantiation - -It is recommended to use a factory to instantiate the `FederatedClient`. - -See the [FederatedClientFactory Example](../examples/FederatedClient/FederatedClientFactory.php). - -```php -use FederatedClient\FederatedClientFactory; - -$config = require 'path/to/config.php'; -$factory = new FederatedClientFactory($config, $logger, $cache); -$client = $factory->build(); - -// Direct instantiation with custom response mode: -use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; -use Cicnavi\Oidc\CodeBooks\ParModeEnum; -$client = new FederatedClient( - entityConfig: $entityConfig, - relyingPartyConfig: $relyingPartyConfig, - responseMode: ResponseModesEnum::FormPost, // Optional - parMode: ParModeEnum::Auto // Optional; Pushed Authorization Requests (RFC 9126) mode. See below. -); -``` - -### Pushed Authorization Requests (PAR, RFC 9126) - -The Federated Client can deliver the authorization request via PAR: the -authorization parameters are POSTed directly to the OP's -`pushed_authorization_request_endpoint`, authenticated with `private_key_jwt`, -and the browser is then sent to the authorization endpoint carrying only -`client_id` and the returned one-time `request_uri`. In this PAR flow plain -authorization parameters are pushed (no signed Request Object is used). - -The `parMode` option (`ParModeEnum`) controls when PAR is used: - -- `ParModeEnum::Off` — never use PAR (the OP rejects the request if it requires PAR). -- `ParModeEnum::Auto` (default) — use PAR only when the OP advertises - `require_pushed_authorization_requests = true`. -- `ParModeEnum::Required` — always use PAR; throws if the OP advertises no - `pushed_authorization_request_endpoint`. - -The mode can also be overridden per call: -`$client->autoRegisterAndAuthenticate($opEntityId, parMode: ParModeEnum::Required)`. - -### 2. Initiating Authentication - -In your login controller, use the `autoRegisterAndAuthenticate` method. This -method takes the Entity ID of the OpenID Provider the user wants to log in with. - -```php - -use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; - -public function login(string $opEntityId) { - /** @var \Cicnavi\Oidc\FederatedClient $client */ - // This will resolve the trust chain, register the client if needed, - // and initiate the authentication flow. You can optionally specify a response mode: - $client->autoRegisterAndAuthenticate($opEntityId, responseMode: ResponseModesEnum::FormPost); -} -``` - -### 3. Handling the Callback - -After the user authenticates at the OP, they are redirected back to your -`redirect_uri` via GET (for query response mode) or POST (for form_post response mode). -Use the `getUserData` method to complete the flow and collect user information. -The client automatically parses and handles both GET and POST requests. - -```php - -public function callback(ServerRequestInterface $request) { - /** @var \Cicnavi\Oidc\FederatedClient $client */ - try { - // Validates the response and returns an array of user claims. - $userData = $client->getUserData($request); - // User is authenticated, $userData contains 'sub', 'email', etc. - } catch (OidcClientException $e) { - // Handle authentication error - } -} -``` - -See the [LoginController Example](../examples/FederatedClient/FederationLoginController.php) -for a sample implementation. - -### 4. RP-Initiated Logout - -If the OP advertised an `end_session_endpoint` in its (resolved) metadata at -login time, you can use the `logout()` method to perform -[OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). -Since the OP is resolved per authorization flow, the end session endpoint is -snapshotted at login time (during `getUserData()`) together with the raw ID -token, and used later when `logout()` is called. - -The client removes the persisted login data (local logout) and delivers a -logout request to the OP's end session endpoint, carrying the ID token as -`id_token_hint`, the RP entity ID as `client_id`, and a `state` parameter. -Note that destroying the application session itself remains the -application's responsibility - but do not destroy the PHP session before -calling `logout()`, since with the default `PhpSessionStore` it also holds -the persisted login data (end session endpoint, ID token). See the note in -the [Pre-Registered Client documentation](2-Pre-Registered-Client.md#rp-initiated-logout) -on session handling around logout. - -```php -/** @var \Cicnavi\Oidc\FederatedClient $client */ - -// Log out the user locally (but do not destroy the PHP session yet), then: -$client->logout( - // Optional. Must be registered as one of this RP's - // 'post_logout_redirect_uris' metadata values (which can be provided - // using the Relying Party configuration additional claims): - postLogoutRedirectUri: 'https://rp.example.org/logged-out', -); -``` - -If a `post_logout_redirect_uri` was provided, validate the redirected -request using `validateLogoutCallback()` (verifies the returned `state`): - -```php -/** @var \Cicnavi\Oidc\FederatedClient $client */ -$client->validateLogoutCallback(); -``` - -## Entity Configuration Endpoint - -To participate in a federation, your RP must publish its -**Entity Configuration** at the well-known endpoint: -`/.well-known/openid-federation` - -You can generate the content for this endpoint using: - -```php -/** @var \Cicnavi\Oidc\FederatedClient $client */ - -// Build the Entity Statement -$entityStatement = $client->buildEntityStatement(); - -// Send the Entity Statement as a JWT -header('Content-Type: application/entity-statement+jwt'); -header('Access-Control-Allow-Origin: *'); - -echo $entityStatement->getToken(); -exit(); -``` - -See the [Entity Configuration Endpoint Example](../examples/FederatedClient/FederationConfigurationController.php) -for a sample implementation. - -## Federation Discovery (from v3.1) - -The client can discover OPs and their metadata using the `FederationDiscovery` -service. - -```php -/** @var \Cicnavi\Oidc\FederatedClient $client */ - -// Optionally define claim paths to sort discovered OPs by their display names -// (e.g., for user-friendly display in a login UI). The paths are relative to -// the OP's metadata structure. The method also has default paths it checks -// if not provided. -$sortClaimPaths = [ - ['metadata', 'openid_provider', 'display_name'], - ['metadata', 'federation_entity', 'display_name'], -]; -$forceRefresh = false; // Set to true to bypass cache and fetch fresh data - -$openIdProvidersPerTrustAnchor = $client->discoverOpenIdProviders($sortClaimPaths, $forceRefresh); - -// The result $openIdProvidersPerTrustAnchor is an associative array where each -// trust anchor ID maps to its list of discovered entities: -// [trustAnchorId => [entityId1 => entityPayload1, entityId2 => entityPayload2, ...]] -// Use it to display available OPs for users to choose from during login. - -``` - -This operation can be time-consuming on the first run because it may require a -full traversal of the federation under each configured Trust Anchor. Results -are cached and subsequent calls are typically much faster. - -To warm up discovery caches (for example, from a CLI command or scheduled job), -you can trigger discovery in advance: - -```php -/** @var \Cicnavi\Oidc\FederatedClient $client */ - -// Warm up OP discovery caches for all configured trust anchors. -// Keep forceRefresh=true only for explicit refresh jobs. -$client->discoverOpenIdProviders(forceRefresh: true); -``` - -### Advanced Discovery - -If you need to discover entities other than OpenID Providers, use -`discoverEntities()` and provide criteria explicitly: - -```php -/** @var \Cicnavi\Oidc\FederatedClient $client */ - -$entitiesPerTrustAnchor = $client->discoverEntities( - criteria: [ - 'entity_type' => ['openid_provider'], - // Optional filters: - // 'trust_mark_type' => ['https://example.org/trust-mark/type'], - // 'query' => 'search text', - ], - sortClaimPaths: [ - ['metadata', 'openid_provider', 'display_name'], - ['metadata', 'federation_entity', 'display_name'], - ], - sortOrder: 'asc', - forceRefresh: false, -); -``` - -`discoverEntities()` returns the same grouped shape: +# Federated Client + +The `FederatedClient` class provides an implementation for an OpenID Connect +Relying Party (RP) that supports **Automatic Client Registration** as defined +in the [OpenID Federation 1.0](https://openid.net/specs/openid-federation-1_0.html) specification. + +## Features + +- **Trust Chain Resolution**: Automatically resolves and validates trust chains +from the OpenID Provider (OP) to a configured Trust Anchor. +- **Automatic Registration**: Dynamically registers the client at the OP during +the first authentication request. +- **Metadata Management**: Handles the generation of the RP's Entity +Configuration and metadata, including keys and trust marks. +- **Federation Discovery**: Supports discovering OPs and their metadata. +- **OIDC Flow**: Manages the authorization code flow, including PKCE, +state/nonce validation, and ID Token verification. +- **Caching**: Efficiently caches resolved trust chains and metadata to improve +performance. + +## Prerequisites + +### PKI + +The Federated Client requires at least two sets of cryptographic keys: +1. **Federation Keys**: Used to sign the RP's Entity Configuration. +2. **Connect Keys**: Used for OIDC protocol operations (e.g., signing Request +Objects or private_key_jwt authentication). + +Sample commands to generate RSA key pairs using OpenSSL: + +```bash +# Generate Federation keys +openssl genrsa -out keys/federation-sig.key 3072 +openssl rsa -in keys/federation-sig.key -pubout -out keys/federation-sig.pub + +# Generate Connect keys +openssl genrsa -out keys/connect-sig.key 3072 +openssl rsa -in keys/connect-sig.key -pubout -out keys/connect-sig.pub +``` + +## Configuration + +The client is configured using `EntityConfig` (for federation-related settings) +and `RelyingPartyConfig` (for OIDC-related settings). + +See the [Configuration Example](../examples/FederatedClient/federated-client-config-example.php) +for a detailed structure. + +### Key Configuration Parameters + +- `entityId`: The unique identifier for your RP (must be a URL). +- `trustAnchorBag`: A collection of trusted root entities (Trust Anchors). +- `authorityHintBag`: A list of immediate superior entities in the federation. +- `redirectUriBag`: Authorized callback URIs for your application. + +## Usage + +### 1. Instantiation + +It is recommended to use a factory to instantiate the `FederatedClient`. + +See the [FederatedClientFactory Example](../examples/FederatedClient/FederatedClientFactory.php). + +```php +use FederatedClient\FederatedClientFactory; + +$config = require 'path/to/config.php'; +$factory = new FederatedClientFactory($config, $logger, $cache); +$client = $factory->build(); + +// Direct instantiation with custom response mode: +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; +use Cicnavi\Oidc\CodeBooks\ParModeEnum; +$client = new FederatedClient( + entityConfig: $entityConfig, + relyingPartyConfig: $relyingPartyConfig, + responseMode: ResponseModesEnum::FormPost, // Optional + parMode: ParModeEnum::Auto // Optional; Pushed Authorization Requests (RFC 9126) mode. See below. +); +``` + +### Pushed Authorization Requests (PAR, RFC 9126) + +The Federated Client can deliver the authorization request via PAR: the +authorization parameters are POSTed directly to the OP's +`pushed_authorization_request_endpoint`, authenticated with `private_key_jwt`, +and the browser is then sent to the authorization endpoint carrying only +`client_id` and the returned one-time `request_uri`. In this PAR flow plain +authorization parameters are pushed (no signed Request Object is used). + +The `parMode` option (`ParModeEnum`) controls when PAR is used: + +- `ParModeEnum::Off` — never use PAR (the OP rejects the request if it requires PAR). +- `ParModeEnum::Auto` (default) — use PAR only when the OP advertises + `require_pushed_authorization_requests = true`. +- `ParModeEnum::Required` — always use PAR; throws if the OP advertises no + `pushed_authorization_request_endpoint`. + +The mode can also be overridden per call: +`$client->autoRegisterAndAuthenticate($opEntityId, parMode: ParModeEnum::Required)`. + +### 2. Initiating Authentication + +In your login controller, use the `autoRegisterAndAuthenticate` method. This +method takes the Entity ID of the OpenID Provider the user wants to log in with. + +```php + +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; + +public function login(string $opEntityId) { + /** @var \Cicnavi\Oidc\FederatedClient $client */ + // This will resolve the trust chain, register the client if needed, + // and initiate the authentication flow. You can optionally specify a response mode: + $client->autoRegisterAndAuthenticate($opEntityId, responseMode: ResponseModesEnum::FormPost); +} +``` + +### 3. Handling the Callback + +After the user authenticates at the OP, they are redirected back to your +`redirect_uri` via GET (for query response mode) or POST (for form_post response mode). +Use the `getUserData` method to complete the flow and collect user information. +The client automatically parses and handles both GET and POST requests. + +```php + +public function callback(ServerRequestInterface $request) { + /** @var \Cicnavi\Oidc\FederatedClient $client */ + try { + // Validates the response and returns an array of user claims. + $userData = $client->getUserData($request); + // User is authenticated, $userData contains 'sub', 'email', etc. + } catch (OidcClientException $e) { + // Handle authentication error + } +} +``` + +See the [LoginController Example](../examples/FederatedClient/FederationLoginController.php) +for a sample implementation. + +### 4. RP-Initiated Logout + +If the OP advertised an `end_session_endpoint` in its (resolved) metadata at +login time, you can use the `logout()` method to perform +[OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html). +Since the OP is resolved per authorization flow, the end session endpoint is +snapshotted at login time (during `getUserData()`) together with the raw ID +token, and used later when `logout()` is called. + +The client removes the persisted login data (local logout) and delivers a +logout request to the OP's end session endpoint, carrying the ID token as +`id_token_hint`, the RP entity ID as `client_id`, and a `state` parameter. +Note that destroying the application session itself remains the +application's responsibility - but do not destroy the PHP session before +calling `logout()`, since with the default `PhpSessionStore` it also holds +the persisted login data (end session endpoint, ID token). See the note in +the [Pre-Registered Client documentation](2-Pre-Registered-Client.md#rp-initiated-logout) +on session handling around logout. + +```php +/** @var \Cicnavi\Oidc\FederatedClient $client */ + +// Log out the user locally (but do not destroy the PHP session yet), then: +$client->logout( + // Optional. Must be registered as one of this RP's + // 'post_logout_redirect_uris' metadata values (which can be provided + // using the Relying Party configuration additional claims): + postLogoutRedirectUri: 'https://rp.example.org/logged-out', +); +``` + +If a `post_logout_redirect_uri` was provided, validate the redirected +request using `validateLogoutCallback()` (verifies the returned `state`): + +```php +/** @var \Cicnavi\Oidc\FederatedClient $client */ +$client->validateLogoutCallback(); +``` + +## Entity Configuration Endpoint + +To participate in a federation, your RP must publish its +**Entity Configuration** at the well-known endpoint: +`/.well-known/openid-federation` + +You can generate the content for this endpoint using: + +```php +/** @var \Cicnavi\Oidc\FederatedClient $client */ + +// Build the Entity Statement +$entityStatement = $client->buildEntityStatement(); + +// Send the Entity Statement as a JWT +header('Content-Type: application/entity-statement+jwt'); +header('Access-Control-Allow-Origin: *'); + +echo $entityStatement->getToken(); +exit(); +``` + +See the [Entity Configuration Endpoint Example](../examples/FederatedClient/FederationConfigurationController.php) +for a sample implementation. + +## Federation Discovery (from v3.1) + +The client can discover OPs and their metadata using the `FederationDiscovery` +service. + +```php +/** @var \Cicnavi\Oidc\FederatedClient $client */ + +// Optionally define claim paths to sort discovered OPs by their display names +// (e.g., for user-friendly display in a login UI). The paths are relative to +// the OP's metadata structure. The method also has default paths it checks +// if not provided. +$sortClaimPaths = [ + ['metadata', 'openid_provider', 'display_name'], + ['metadata', 'federation_entity', 'display_name'], +]; +$forceRefresh = false; // Set to true to bypass cache and fetch fresh data + +$openIdProvidersPerTrustAnchor = $client->discoverOpenIdProviders($sortClaimPaths, $forceRefresh); + +// The result $openIdProvidersPerTrustAnchor is an associative array where each +// trust anchor ID maps to its list of discovered entities: +// [trustAnchorId => [entityId1 => entityPayload1, entityId2 => entityPayload2, ...]] +// Use it to display available OPs for users to choose from during login. + +``` + +This operation can be time-consuming on the first run because it may require a +full traversal of the federation under each configured Trust Anchor. Results +are cached and subsequent calls are typically much faster. + +To warm up discovery caches (for example, from a CLI command or scheduled job), +you can trigger discovery in advance: + +```php +/** @var \Cicnavi\Oidc\FederatedClient $client */ + +// Warm up OP discovery caches for all configured trust anchors. +// Keep forceRefresh=true only for explicit refresh jobs. +$client->discoverOpenIdProviders(forceRefresh: true); +``` + +### Advanced Discovery + +If you need to discover entities other than OpenID Providers, use +`discoverEntities()` and provide criteria explicitly: + +```php +/** @var \Cicnavi\Oidc\FederatedClient $client */ + +$entitiesPerTrustAnchor = $client->discoverEntities( + criteria: [ + 'entity_type' => ['openid_provider'], + // Optional filters: + // 'trust_mark_type' => ['https://example.org/trust-mark/type'], + // 'query' => 'search text', + ], + sortClaimPaths: [ + ['metadata', 'openid_provider', 'display_name'], + ['metadata', 'federation_entity', 'display_name'], + ], + sortOrder: 'asc', + forceRefresh: false, +); +``` + +`discoverEntities()` returns the same grouped shape: `[trustAnchorId => [entityId => entityPayload, ...]]`. \ No newline at end of file diff --git a/examples/FederatedClient/FederationDiscoveryController.php b/examples/FederatedClient/FederationDiscoveryController.php index e406ec1..b355a06 100644 --- a/examples/FederatedClient/FederationDiscoveryController.php +++ b/examples/FederatedClient/FederationDiscoveryController.php @@ -1,135 +1,135 @@ -getQueryParams(); - $forceRefresh = $this->parseBool($queryParams['force_refresh'] ?? null); - - $providersPerTrustAnchor = $this->federatedClient->discoverOpenIdProviders( - sortClaimPaths: [ - ['metadata', 'openid_provider', 'display_name'], - ['metadata', 'federation_entity', 'display_name'], - ], - forceRefresh: $forceRefresh, - ); - - $this->logger->info('OpenID Provider discovery completed.', [ - 'force_refresh' => $forceRefresh, - 'trust_anchors' => count($providersPerTrustAnchor), - ]); - - return $providersPerTrustAnchor; - } - - /** - * Discover entities grouped by trust anchor using custom criteria. - * - * Query params: - * - entity_type=openid_provider,federation_entity (optional) - * - trust_mark_type=https://example.org/tm/1,https://example.org/tm/2 (optional) - * - query=search text (optional) - * - sort_order=asc|desc (optional, default: asc) - * - force_refresh=1|true (optional) - */ - public function entities(ServerRequestInterface $request): array - { - $queryParams = $request->getQueryParams(); - - $criteria = array_filter([ - 'entity_type' => $this->parseCsv($queryParams['entity_type'] ?? null), - 'trust_mark_type' => $this->parseCsv($queryParams['trust_mark_type'] ?? null), - 'query' => $this->parseString($queryParams['query'] ?? null), - ], static fn (mixed $value): bool => $value !== null && $value !== []); - - $sortOrder = $this->parseSortOrder($queryParams['sort_order'] ?? null); - $forceRefresh = $this->parseBool($queryParams['force_refresh'] ?? null); - - $entitiesPerTrustAnchor = $this->federatedClient->discoverEntities( - criteria: $criteria, - sortClaimPaths: [ - ['metadata', 'openid_provider', 'display_name'], - ['metadata', 'federation_entity', 'display_name'], - ], - sortOrder: $sortOrder, - forceRefresh: $forceRefresh, - ); - - $this->logger->info('Federation entity discovery completed.', [ - 'criteria' => $criteria, - 'sort_order' => $sortOrder, - 'force_refresh' => $forceRefresh, - 'trust_anchors' => count($entitiesPerTrustAnchor), - ]); - - return $entitiesPerTrustAnchor; - } - - private function parseSortOrder(mixed $sortOrder): string - { - if (!is_string($sortOrder)) { - return 'asc'; - } - - return strtolower($sortOrder) === 'desc' ? 'desc' : 'asc'; - } - - private function parseCsv(mixed $value): ?array - { - if (!is_string($value) || trim($value) === '') { - return null; - } - - $values = array_values(array_filter(array_map( - static fn (string $item): string => trim($item), - explode(',', $value) - ))); - - return $values === [] ? null : $values; - } - - private function parseString(mixed $value): ?string - { - if (!is_string($value) || trim($value) === '') { - return null; - } - - return trim($value); - } - - private function parseBool(mixed $value): bool - { - if (is_bool($value)) { - return $value; - } - - if (!is_string($value)) { - return false; - } - - return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); - } -} +getQueryParams(); + $forceRefresh = $this->parseBool($queryParams['force_refresh'] ?? null); + + $providersPerTrustAnchor = $this->federatedClient->discoverOpenIdProviders( + sortClaimPaths: [ + ['metadata', 'openid_provider', 'display_name'], + ['metadata', 'federation_entity', 'display_name'], + ], + forceRefresh: $forceRefresh, + ); + + $this->logger->info('OpenID Provider discovery completed.', [ + 'force_refresh' => $forceRefresh, + 'trust_anchors' => count($providersPerTrustAnchor), + ]); + + return $providersPerTrustAnchor; + } + + /** + * Discover entities grouped by trust anchor using custom criteria. + * + * Query params: + * - entity_type=openid_provider,federation_entity (optional) + * - trust_mark_type=https://example.org/tm/1,https://example.org/tm/2 (optional) + * - query=search text (optional) + * - sort_order=asc|desc (optional, default: asc) + * - force_refresh=1|true (optional) + */ + public function entities(ServerRequestInterface $request): array + { + $queryParams = $request->getQueryParams(); + + $criteria = array_filter([ + 'entity_type' => $this->parseCsv($queryParams['entity_type'] ?? null), + 'trust_mark_type' => $this->parseCsv($queryParams['trust_mark_type'] ?? null), + 'query' => $this->parseString($queryParams['query'] ?? null), + ], static fn (mixed $value): bool => $value !== null && $value !== []); + + $sortOrder = $this->parseSortOrder($queryParams['sort_order'] ?? null); + $forceRefresh = $this->parseBool($queryParams['force_refresh'] ?? null); + + $entitiesPerTrustAnchor = $this->federatedClient->discoverEntities( + criteria: $criteria, + sortClaimPaths: [ + ['metadata', 'openid_provider', 'display_name'], + ['metadata', 'federation_entity', 'display_name'], + ], + sortOrder: $sortOrder, + forceRefresh: $forceRefresh, + ); + + $this->logger->info('Federation entity discovery completed.', [ + 'criteria' => $criteria, + 'sort_order' => $sortOrder, + 'force_refresh' => $forceRefresh, + 'trust_anchors' => count($entitiesPerTrustAnchor), + ]); + + return $entitiesPerTrustAnchor; + } + + private function parseSortOrder(mixed $sortOrder): string + { + if (!is_string($sortOrder)) { + return 'asc'; + } + + return strtolower($sortOrder) === 'desc' ? 'desc' : 'asc'; + } + + private function parseCsv(mixed $value): ?array + { + if (!is_string($value) || trim($value) === '') { + return null; + } + + $values = array_values(array_filter(array_map( + static fn (string $item): string => trim($item), + explode(',', $value) + ))); + + return $values === [] ? null : $values; + } + + private function parseString(mixed $value): ?string + { + if (!is_string($value) || trim($value) === '') { + return null; + } + + return trim($value); + } + + private function parseBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + if (!is_string($value)) { + return false; + } + + return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + } +} From 7c907a33f64a1d03a8adca282810211635c4321b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 5 Jul 2026 10:19:16 +0200 Subject: [PATCH 05/10] Use session data instead of doing registration --- src/DynamicallyRegisteredClient.php | 163 ++++++++++++++--- src/FederatedClient.php | 28 +-- src/Helpers/HttpHelper.php | 46 +++++ src/PreRegisteredClient.php | 28 +-- .../Oidc/DynamicallyRegisteredClientTest.php | 168 +++++++++++++++--- 5 files changed, 345 insertions(+), 88 deletions(-) diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php index 9c3e6bf..23eb93c 100644 --- a/src/DynamicallyRegisteredClient.php +++ b/src/DynamicallyRegisteredClient.php @@ -10,6 +10,7 @@ use Cicnavi\Oidc\DataStore\Interfaces\SessionStoreInterface; use Cicnavi\Oidc\DataStore\PhpSessionStore; use Cicnavi\Oidc\Exceptions\OidcClientException; +use Cicnavi\Oidc\Helpers\HttpHelper; use Cicnavi\Oidc\Interfaces\MetadataInterface; use Cicnavi\Oidc\Protocol\ClientRegistrationHandler; use Cicnavi\Oidc\Protocol\OpMetadata; @@ -90,6 +91,12 @@ class DynamicallyRegisteredClient protected ?ClientRegistrationData $registrationData = null; + /** + * Request data handler resolved for session-stored login / logout data + * operations (see resolveRequestDataHandler()). + */ + protected ?RequestDataHandler $resolvedRequestDataHandler = null; + /** * DynamicallyRegisteredClient constructor. * @@ -638,15 +645,29 @@ public function getUserData(?ServerRequestInterface $request = null): array } /** - * Perform RP-Initiated Logout using the underlying pre-registered client - * instance built from the current client registration. + * Perform RP-Initiated Logout: remove the login data persisted in the + * session store (local logout) and deliver a logout request to the OP's + * end session endpoint, carrying the ID token received at login as + * 'id_token_hint'. + * + * Logout only needs session-stored login data and OP metadata, so no + * client registration is performed or updated here - logout must not be + * blocked by dynamic client registration state (stale, expired, or + * missing registration, unavailable registration endpoint...). The + * 'client_id' logout parameter is the login-time client ID from the + * persisted login data, with a read-only fallback to the client ID of + * the persisted client registration. + * + * For notes on application session handling around logout, refer to + * PreRegisteredClient::logout(). * * @param ?string $postLogoutRedirectUri URI to which the OP should * redirect the user agent after logout. Must be one of the * 'post_logout_redirect_uris' registered on the OP (see the * $postLogoutRedirectUris constructor parameter). * @see PreRegisteredClient::logout() - * @throws OidcClientException + * @throws OidcClientException If the OP does not advertise an + * 'end_session_endpoint'. */ public function logout( ?string $postLogoutRedirectUri = null, @@ -655,50 +676,152 @@ public function logout( AuthorizationRequestMethodEnum $logoutRequestMethod = AuthorizationRequestMethodEnum::Query, ?ResponseInterface $response = null, ): ?ResponseInterface { - return $this->resolvePreRegisteredClient()->logout( - $postLogoutRedirectUri, - $logoutHint, - $uiLocales, + $requestDataHandler = $this->resolveRequestDataHandler(); + + $endSessionEndpoint = $requestDataHandler->getLoginEndSessionEndpoint() ?? + $this->getOptionalMetadataString(ClaimsEnum::EndSessionEndpoint->value); + + if (!is_string($endSessionEndpoint)) { + throw new OidcClientException( + 'End session endpoint not found in OP metadata, so RP-Initiated Logout is not available.', + ); + } + + $idTokenHint = $requestDataHandler->getLoginIdToken(); + + if ($idTokenHint === null) { + $this->logger?->warning( + 'No ID token found in persisted login data, sending RP-Initiated Logout request without ' . + '"id_token_hint". The OpenID Provider may refuse the request or prompt the user for ' . + 'confirmation. If the application session was destroyed before calling logout(), destroy ' . + 'it after the logout request is prepared instead (see logout() documentation).', + ); + } + + $parameters = $requestDataHandler->buildEndSessionParameters( + idTokenHint: $idTokenHint, + // Prefer the client ID the login was performed with, so it + // matches the 'id_token_hint' even if the client registration + // changed in the meantime. The fallback only reads the persisted + // registration - no registration is performed or updated. + clientId: $requestDataHandler->getLoginClientId() ?? $this->loadRegistrationData()?->getClientId(), + postLogoutRedirectUri: $postLogoutRedirectUri, + state: $this->useState ? $requestDataHandler->getLogoutState() : null, + logoutHint: $logoutHint, + uiLocales: $uiLocales, + ); + + $this->logger?->debug('Logout request parameters', $parameters); + + // Local logout: remove persisted login data. + $requestDataHandler->clearLoginData(); + + return HttpHelper::dispatchFrontChannelRequest( + $endSessionEndpoint, + $parameters, $logoutRequestMethod, $response, + $this->logger, ); } /** * Validate the request made to the post logout redirect URI after an - * RP-Initiated Logout. + * RP-Initiated Logout (the OP must return the logout state parameter + * unchanged). No-op when this client is configured not to use state. * - * @see PreRegisteredClient::validateLogoutCallback() - * @throws OidcClientException + * Only verifies the logout state against the session store - no client + * registration is performed or updated here. + * + * @throws OidcClientException If the state parameter is missing or does + * not match the one sent in the logout request. */ public function validateLogoutCallback(?ServerRequestInterface $request = null): void { - $this->resolvePreRegisteredClient()->validateLogoutCallback($request); + $this->resolveRequestDataHandler()->validateLogoutCallbackResponse($request, $this->useState); } /** * Raw ID token received at the last successful login, or null when not - * available. - * - * @see PreRegisteredClient::getIdToken() - * @throws OidcClientException + * available. Read from the session store - no client registration is + * performed or updated here. */ public function getIdToken(): ?string { - return $this->resolvePreRegisteredClient()->getIdToken(); + return $this->resolveRequestDataHandler()->getLoginIdToken(); } /** * Login data persisted at the last successful login, or null when not - * available. + * available. Read from the session store - no client registration is + * performed or updated here. * * @return mixed[]|null - * @see PreRegisteredClient::getLoginData() - * @throws OidcClientException */ public function getLoginData(): ?array { - return $this->resolvePreRegisteredClient()->getLoginData(); + return $this->resolveRequestDataHandler()->getLoginData(); + } + + /** + * Get the request data handler used for session-stored login / logout + * data operations. Uses the constructor-provided instance when + * available, otherwise lazily builds one over the same session store + * that the underlying pre-registered client instances use (so + * session-stored data is shared either way). Never performs client + * registration. + */ + protected function resolveRequestDataHandler(): RequestDataHandler + { + if ($this->resolvedRequestDataHandler instanceof RequestDataHandler) { + return $this->resolvedRequestDataHandler; + } + + if ($this->requestDataHandler instanceof RequestDataHandler) { + return $this->resolvedRequestDataHandler = $this->requestDataHandler; + } + + $core = $this->core ?? new Core( + $this->supportedAlgorithms, + $this->supportedSerializers, + $this->timestampValidationLeeway, + $this->logger, + ); + + $jwks = $this->jwks ?? new Jwks( + supportedAlgorithms: $this->supportedAlgorithms, + supportedSerializers: $this->supportedSerializers, + maxCacheDuration: $this->maxCacheDuration, + timestampValidationLeeway: $this->timestampValidationLeeway, + cache: $this->cache, + logger: $this->logger, + httpClient: $this->httpClient, + ); + + return $this->resolvedRequestDataHandler = new RequestDataHandler( + sessionStore: $this->sessionStore, + core: $core, + cache: $this->cache, + jwks: $jwks, + httpClient: $this->httpClient, + logger: $this->logger, + maxCacheDuration: $this->maxCacheDuration, + ); + } + + /** + * Read an optional string value from OP metadata, returning null when the + * key is not advertised or its value is not a non-empty string. + */ + protected function getOptionalMetadataString(string $key): ?string + { + try { + $value = $this->metadata->get($key); + } catch (OidcClientException) { + return null; + } + + return (is_string($value) && $value !== '') ? $value : null; } /** diff --git a/src/FederatedClient.php b/src/FederatedClient.php index dd72efc..8b2f0ad 100644 --- a/src/FederatedClient.php +++ b/src/FederatedClient.php @@ -826,27 +826,13 @@ protected function dispatchAuthorizationRequest( AuthorizationRequestMethodEnum $authorizationRequestMethod, ?ResponseInterface $response, ): ?ResponseInterface { - if ($authorizationRequestMethod === AuthorizationRequestMethodEnum::FormPost) { - $formHtml = HttpHelper::generateAutoSubmitPostForm($opAuthorizationEndpoint, $authorizationParameters); - if ($response instanceof ResponseInterface) { - $this->logger?->debug('Returning FormPost HTML in response body.'); - $response->getBody()->write($formHtml); - return $response->withHeader('Content-Type', 'text/html'); - } - - echo $formHtml; - exit; - } - - $opAuthorizationEndpointUri = $opAuthorizationEndpoint . '?' . http_build_query($authorizationParameters); - - if ($response instanceof ResponseInterface) { - $this->logger?->debug('Redirecting.', ['endpoint' => $opAuthorizationEndpoint]); - return $response->withHeader('Location', $opAuthorizationEndpointUri); - } - - header('Location: ' . $opAuthorizationEndpointUri); - exit; + return HttpHelper::dispatchFrontChannelRequest( + $opAuthorizationEndpoint, + $authorizationParameters, + $authorizationRequestMethod, + $response, + $this->logger, + ); } protected function resolveClientRedirectUriForAuthorizationRequest(?string $specificRedirectUri): string diff --git a/src/Helpers/HttpHelper.php b/src/Helpers/HttpHelper.php index 8d0939a..ea64bc2 100644 --- a/src/Helpers/HttpHelper.php +++ b/src/Helpers/HttpHelper.php @@ -4,6 +4,10 @@ namespace Cicnavi\Oidc\Helpers; +use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; +use Psr\Http\Message\ResponseInterface; +use Psr\Log\LoggerInterface; + /** * @see \Cicnavi\Tests\Oidc\Helpers\HttpHelperTest */ @@ -125,4 +129,46 @@ public static function generateAutoSubmitPostForm(string $url, array $parameters $inputs ); } + + /** + * Deliver a front-channel request (authorization request, RP-Initiated + * Logout request) to the given endpoint, either as an auto-submitting + * POST form or as a redirect with parameters in the query string, + * depending on the method. + * + * If a PSR-7 response instance is provided, it is populated with the + * proper headers / body and returned. Otherwise, output is emitted + * directly and the script is terminated. + * + * @param array $parameters + */ + public static function dispatchFrontChannelRequest( + string $endpoint, + array $parameters, + AuthorizationRequestMethodEnum $requestMethod, + ?ResponseInterface $response = null, + ?LoggerInterface $logger = null, + ): ?ResponseInterface { + if ($requestMethod === AuthorizationRequestMethodEnum::FormPost) { + $formHtml = self::generateAutoSubmitPostForm($endpoint, $parameters); + if ($response instanceof ResponseInterface) { + $logger?->debug('Returning FormPost HTML in response body.'); + $response->getBody()->write($formHtml); + return $response->withHeader('Content-Type', 'text/html'); + } + + echo $formHtml; + exit; + } + + $uri = $endpoint . '?' . http_build_query($parameters); + + if ($response instanceof ResponseInterface) { + $logger?->debug('Redirecting.', ['endpoint' => $endpoint]); + return $response->withHeader('Location', $uri); + } + + header('Location: ' . $uri); + exit; + } } diff --git a/src/PreRegisteredClient.php b/src/PreRegisteredClient.php index 5aca48f..240a0d8 100644 --- a/src/PreRegisteredClient.php +++ b/src/PreRegisteredClient.php @@ -287,27 +287,13 @@ protected function dispatchFrontChannelRequest( AuthorizationRequestMethodEnum $requestMethod, ?ResponseInterface $response, ): ?ResponseInterface { - if ($requestMethod === AuthorizationRequestMethodEnum::FormPost) { - $formHtml = HttpHelper::generateAutoSubmitPostForm($endpoint, $parameters); - if ($response instanceof ResponseInterface) { - $this->logger?->debug('Returning FormPost HTML in response body.'); - $response->getBody()->write($formHtml); - return $response->withHeader('Content-Type', 'text/html'); - } - - echo $formHtml; - exit; - } - - $redirectUri = $endpoint . '?' . http_build_query($parameters); - - if ($response instanceof ResponseInterface) { - $this->logger?->debug('Redirecting.', ['endpoint' => $endpoint]); - return $response->withHeader('Location', $redirectUri); - } - - header('Location: ' . $redirectUri); - exit; + return HttpHelper::dispatchFrontChannelRequest( + $endpoint, + $parameters, + $requestMethod, + $response, + $this->logger, + ); } /** diff --git a/tests/Oidc/DynamicallyRegisteredClientTest.php b/tests/Oidc/DynamicallyRegisteredClientTest.php index 19b767b..6082858 100644 --- a/tests/Oidc/DynamicallyRegisteredClientTest.php +++ b/tests/Oidc/DynamicallyRegisteredClientTest.php @@ -23,6 +23,7 @@ #[UsesClass(PreRegisteredClient::class)] #[UsesClass(\Cicnavi\Oidc\DataStore\DataHandlers\AbstractDataHandler::class)] #[UsesClass(\Cicnavi\Oidc\Protocol\RequestDataHandler::class)] +#[UsesClass(\Cicnavi\Oidc\Helpers\HttpHelper::class)] final class DynamicallyRegisteredClientTest extends TestCase { protected string $opConfigurationUrl = 'https://op.example.org/.well-known/openid-configuration'; @@ -56,6 +57,8 @@ final class DynamicallyRegisteredClientTest extends TestCase protected MockObject $sessionStoreMock; + protected MockObject $requestDataHandlerMock; + protected function setUp(): void { $this->registrationStoreMock = $this->createMock(ClientRegistrationStoreInterface::class); @@ -64,6 +67,7 @@ protected function setUp(): void $this->cacheMock = $this->createMock(\Psr\SimpleCache\CacheInterface::class); $this->preRegisteredClientMock = $this->createMock(PreRegisteredClient::class); $this->sessionStoreMock = $this->createMock(SessionStoreInterface::class); + $this->requestDataHandlerMock = $this->createMock(\Cicnavi\Oidc\Protocol\RequestDataHandler::class); // By default, keep cache valid to avoid side-effects in most tests. $this->cacheMock->method('get')->with('OIDC_OP_CONFIGURATION_URL') @@ -85,6 +89,8 @@ protected function sut( ?PreRegisteredClient $preRegisteredClient = null, bool $injectPreRegisteredClient = true, array $postLogoutRedirectUris = [], + ?\Cicnavi\Oidc\Protocol\RequestDataHandler $requestDataHandler = null, + bool $injectRequestDataHandler = true, ): DynamicallyRegisteredClient { $registrationStore ??= $this->registrationStoreMock; $cache ??= $this->cacheMock; @@ -95,6 +101,10 @@ protected function sut( $preRegisteredClient ??= $this->preRegisteredClientMock; } + if ($injectRequestDataHandler) { + $requestDataHandler ??= $this->requestDataHandlerMock; + } + $this->assertInstanceOf(ClientRegistrationStoreInterface::class, $registrationStore); $this->assertInstanceOf(ClientRegistrationHandler::class, $registrationHandler); $this->assertInstanceOf(SessionStoreInterface::class, $this->sessionStoreMock); @@ -113,6 +123,7 @@ protected function sut( sessionStore: $this->sessionStoreMock, httpClient: $this->createStub(\GuzzleHttp\Client::class), metadata: $metadata, + requestDataHandler: $requestDataHandler, registrationHandler: $registrationHandler, preRegisteredClient: $preRegisteredClient, postLogoutRedirectUris: $postLogoutRedirectUris, @@ -509,62 +520,167 @@ public function testThrowsWhenPostLogoutRedirectUrisOverrideExcludesConfiguredUr ); } - public function testLogoutDelegatesToPreRegisteredClient(): void + public function testLogoutUsesLoginDataWithoutPerformingRegistration(): void { - $this->registrationStoreMock->method('get')->willReturn( - $this->clientInformationResponseWithCurrentFingerprint(), - ); + // Logout must not perform or update client registration. + $this->registrationHandlerMock->expects($this->never())->method('register'); + $this->registrationHandlerMock->expects($this->never())->method('update'); + // No persisted registration exists at all - logout still works from + // session-stored login data. + $this->registrationStoreMock->method('get')->willReturn(null); - $this->preRegisteredClientMock->expects($this->once()) - ->method('logout') + $this->requestDataHandlerMock->method('getLoginEndSessionEndpoint') + ->willReturn('https://op.example.org/end-session'); + $this->requestDataHandlerMock->method('getLoginIdToken')->willReturn('id-token'); + $this->requestDataHandlerMock->method('getLoginClientId')->willReturn('login-client-id'); + $this->requestDataHandlerMock->method('getLogoutState')->willReturn('logout-state'); + $this->requestDataHandlerMock->expects($this->once()) + ->method('buildEndSessionParameters') ->with( + 'id-token', + 'login-client-id', 'https://rp.example.org/logged-out', + 'logout-state', null, null, - \Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum::Query, - null, ) - ->willReturn(null); + ->willReturn(['id_token_hint' => 'id-token']); + $this->requestDataHandlerMock->expects($this->once())->method('clearLoginData'); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once()) + ->method('withHeader') + ->with( + 'Location', + $this->callback(fn(string $location): bool => str_starts_with( + $location, + 'https://op.example.org/end-session?' + ) && str_contains($location, 'id_token_hint=id-token')) + ) + ->willReturn($response); - $this->assertNotInstanceOf( - \Psr\Http\Message\ResponseInterface::class, - $this->sut()->logout('https://rp.example.org/logged-out'), + $result = $this->sut()->logout( + postLogoutRedirectUri: 'https://rp.example.org/logged-out', + response: $response, ); + $this->assertSame($response, $result); } - public function testValidateLogoutCallbackDelegatesToPreRegisteredClient(): void + public function testLogoutFallsBackToPersistedRegistrationClientId(): void { - $this->registrationStoreMock->method('get')->willReturn( - $this->clientInformationResponseWithCurrentFingerprint(), + $this->registrationHandlerMock->expects($this->never())->method('register'); + $this->registrationHandlerMock->expects($this->never())->method('update'); + // Persisted registration exists (note: without the current metadata + // fingerprint, which would trigger an update if registration was + // resolved) - only its client ID is read. + $this->registrationStoreMock->method('get')->willReturn($this->clientInformationResponse); + + $this->requestDataHandlerMock->method('getLoginEndSessionEndpoint') + ->willReturn('https://op.example.org/end-session'); + $this->requestDataHandlerMock->method('getLoginClientId')->willReturn(null); + $this->requestDataHandlerMock->method('getLogoutState')->willReturn('logout-state'); + $this->requestDataHandlerMock->expects($this->once()) + ->method('buildEndSessionParameters') + ->with(null, 'client-id', null, 'logout-state', null, null) + ->willReturn([]); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->method('withHeader')->willReturn($response); + + $result = $this->sut()->logout(response: $response); + $this->assertSame($response, $result); + } + + public function testLogoutUsesEndSessionEndpointFromOpMetadataAsFallback(): void + { + $this->registrationHandlerMock->expects($this->never())->method('register'); + + $this->metadataMock->expects($this->exactly(1))->method('get')->willReturnMap([ + ['end_session_endpoint', 'https://op.example.org/end-session'], + ]); + + $this->requestDataHandlerMock->method('getLoginEndSessionEndpoint')->willReturn(null); + $this->requestDataHandlerMock->method('getLogoutState')->willReturn('logout-state'); + $this->requestDataHandlerMock->method('buildEndSessionParameters')->willReturn([]); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once()) + ->method('withHeader') + ->with( + 'Location', + $this->callback(fn(string $location): bool => str_starts_with( + $location, + 'https://op.example.org/end-session' + )) + ) + ->willReturn($response); + + $result = $this->sut()->logout(response: $response); + $this->assertSame($response, $result); + } + + public function testLogoutThrowsWhenEndSessionEndpointNotAvailable(): void + { + $this->requestDataHandlerMock->method('getLoginEndSessionEndpoint')->willReturn(null); + $this->metadataMock->method('get')->willThrowException( + new OidcClientException('OIDC metadata parameter not supported'), ); - $this->preRegisteredClientMock->expects($this->once())->method('validateLogoutCallback'); + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('End session endpoint not found in OP metadata'); + + $this->sut()->logout(); + } + + public function testValidateLogoutCallbackDoesNotTouchRegistration(): void + { + $this->registrationHandlerMock->expects($this->never())->method('register'); + $this->registrationHandlerMock->expects($this->never())->method('update'); + $this->registrationStoreMock->expects($this->never())->method('get'); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutCallbackResponse') + ->with(null, true); $this->sut()->validateLogoutCallback(); } - public function testGetIdTokenDelegatesToPreRegisteredClient(): void + public function testGetIdTokenUsesSessionLoginData(): void { - $this->registrationStoreMock->method('get')->willReturn( - $this->clientInformationResponseWithCurrentFingerprint(), - ); + $this->registrationHandlerMock->expects($this->never())->method('register'); + $this->registrationStoreMock->expects($this->never())->method('get'); - $this->preRegisteredClientMock->method('getIdToken')->willReturn('id-token'); + $this->requestDataHandlerMock->method('getLoginIdToken')->willReturn('id-token'); $this->assertSame('id-token', $this->sut()->getIdToken()); } - public function testGetLoginDataDelegatesToPreRegisteredClient(): void + public function testGetLoginDataUsesSessionLoginData(): void { - $this->registrationStoreMock->method('get')->willReturn( - $this->clientInformationResponseWithCurrentFingerprint(), - ); + $this->registrationHandlerMock->expects($this->never())->method('register'); + $this->registrationStoreMock->expects($this->never())->method('get'); - $this->preRegisteredClientMock->method('getLoginData')->willReturn(['id_token' => 'id-token']); + $this->requestDataHandlerMock->method('getLoginData')->willReturn(['id_token' => 'id-token']); $this->assertSame(['id_token' => 'id-token'], $this->sut()->getLoginData()); } + public function testGetIdTokenWithLazilyBuiltRequestDataHandler(): void + { + $this->registrationHandlerMock->expects($this->never())->method('register'); + + // Login data is read from the session store via a lazily built + // request data handler (no instance provided in constructor). + $this->sessionStoreMock->method('get') + ->with(\Cicnavi\Oidc\Protocol\RequestDataHandler::KEY_LOGIN_DATA) + ->willReturn(['id_token' => 'id-token']); + + $this->assertSame( + 'id-token', + $this->sut(injectRequestDataHandler: false)->getIdToken(), + ); + } + public function testGetUserDataDelegatesToPreRegisteredClient(): void { $this->registrationStoreMock->method('get')->willReturn( From 591ce786b8fc1ae3c8579ab3a6932d67bc744090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 5 Jul 2026 10:23:29 +0200 Subject: [PATCH 06/10] Update tests --- tests/Oidc/Helpers/HttpHelperTest.php | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/Oidc/Helpers/HttpHelperTest.php b/tests/Oidc/Helpers/HttpHelperTest.php index 297fd9c..d306499 100644 --- a/tests/Oidc/Helpers/HttpHelperTest.php +++ b/tests/Oidc/Helpers/HttpHelperTest.php @@ -131,4 +131,58 @@ public function testGenerateAutoSubmitPostForm(): void $this->assertStringContainsString('name="scope" value="openid profile"', $html); $this->assertStringContainsString('onload="document.forms[0].submit()"', $html); } + + public function testDispatchFrontChannelRequestQueryPopulatesLocationHeader(): void + { + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once()) + ->method('withHeader') + ->with( + 'Location', + 'https://example.com/auth?client_id=test_client&scope=openid+profile', + ) + ->willReturn($response); + + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $logger->expects($this->once())->method('debug'); + + $result = HttpHelper::dispatchFrontChannelRequest( + 'https://example.com/auth', + [ + 'client_id' => 'test_client', + 'scope' => 'openid profile', + ], + \Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum::Query, + $response, + $logger, + ); + + $this->assertSame($response, $result); + } + + public function testDispatchFrontChannelRequestFormPostPopulatesResponseBody(): void + { + $body = $this->createMock(\Psr\Http\Message\StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->callback(fn(string $html): bool => + str_contains($html, 'action="https://example.com/auth"') && + str_contains($html, 'name="client_id" value="test_client"'))); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->method('getBody')->willReturn($body); + $response->expects($this->once()) + ->method('withHeader') + ->with('Content-Type', 'text/html') + ->willReturn($response); + + $result = HttpHelper::dispatchFrontChannelRequest( + 'https://example.com/auth', + ['client_id' => 'test_client'], + \Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum::FormPost, + $response, + ); + + $this->assertSame($response, $result); + } } From 056e854a570290493a1abce43f9b4d20f647c05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 5 Jul 2026 10:51:55 +0200 Subject: [PATCH 07/10] Start with RP Initiated conformance tests --- .../conformance-rp-logout-ci.json | 27 +++++++++++++++++++ .../conformance-rp-logout-dynamic-ci.json | 18 +++++++++++++ conformance-tests/trigger-client.py | 4 +++ 3 files changed, 49 insertions(+) create mode 100644 conformance-tests/conformance-rp-logout-ci.json create mode 100644 conformance-tests/conformance-rp-logout-dynamic-ci.json diff --git a/conformance-tests/conformance-rp-logout-ci.json b/conformance-tests/conformance-rp-logout-ci.json new file mode 100644 index 0000000..b22525a --- /dev/null +++ b/conformance-tests/conformance-rp-logout-ci.json @@ -0,0 +1,27 @@ +{ + "alias": "oidc-client-php", + "description": "OIDC RP-Initiated Logout conformance tests for oidc-client-php", + "client": { + "client_id": "oidc-client-php-test", + "client_secret": "oidc-client-php-test-secret", + "redirect_uri": "https://rp.local.conformance.test/callback", + "post_logout_redirect_uris": [ + "https://rp.local.conformance.test/logout-callback" + ], + "request_type": "plain_http_request" + }, + "browser": [ + { + "match": "https://rp.local.conformance.test*", + "tasks": [ + { + "task": "Trigger RP login with logout and wait for completion", + "match": "https://rp.local.conformance.test/", + "commands": [ + ["wait", "id", "submission_complete", 30] + ] + } + ] + } + ] +} diff --git a/conformance-tests/conformance-rp-logout-dynamic-ci.json b/conformance-tests/conformance-rp-logout-dynamic-ci.json new file mode 100644 index 0000000..4c72a5b --- /dev/null +++ b/conformance-tests/conformance-rp-logout-dynamic-ci.json @@ -0,0 +1,18 @@ +{ + "alias": "oidc-client-php", + "description": "OIDC RP-Initiated Logout conformance tests for oidc-client-php (dynamic client registration)", + "browser": [ + { + "match": "https://rp.local.conformance.test*", + "tasks": [ + { + "task": "Trigger RP login with logout and wait for completion", + "match": "https://rp.local.conformance.test/", + "commands": [ + ["wait", "id", "submission_complete", 30] + ] + } + ] + } + ] +} diff --git a/conformance-tests/trigger-client.py b/conformance-tests/trigger-client.py index 9613654..8eaf054 100644 --- a/conformance-tests/trigger-client.py +++ b/conformance-tests/trigger-client.py @@ -95,6 +95,10 @@ def trigger_rp(): print(f"Trigger request completed. Status: {trigger_resp.status_code}", flush=True) if "submission_complete" in trigger_resp.text: print("SUCCESS: submission_complete found in response!", flush=True) + elif "logout_rejected" in trigger_resp.text: + # Negative RP-Initiated Logout test modules: the RP + # completed the flow by rejecting the logout callback. + print("SUCCESS: logout_rejected found in response!", flush=True) else: print("WARNING: submission_complete NOT found in response!", flush=True) print(trigger_resp.text[:1000], flush=True) # Print first 1000 chars of response for debug From 133a3657b6fb8bab1bd440f9483416caa018abcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 5 Jul 2026 10:55:23 +0200 Subject: [PATCH 08/10] Start with conformance tests --- .github/workflows/build.yml | 29 +++++++++++++- .gitignore | 3 +- docker/docker-compose.yml | 4 ++ docker/rp-app/index.php | 40 ++++++++++++++++++- docs/5-Conformance-Testing.md | 33 +++++++++++++++ tests/Oidc/FederatedClientTest.php | 15 +++++++ tests/Oidc/Federation/EntityConfigTest.php | 18 +++++++++ .../Federation/RelyingPartyConfigTest.php | 12 ++++++ tests/Oidc/PreRegisteredClientTest.php | 21 ++++++++++ .../Oidc/Protocol/RequestDataHandlerTest.php | 3 ++ 10 files changed, 174 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f9d5d6a..a37e4b5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,8 @@ name: Build on: push: - branches: [ master, v3.x ] + # TODO: remove 'logout' after the branch is merged. + branches: [ master, v3.x, logout ] pull_request: branches: [ master ] @@ -76,6 +77,32 @@ jobs: - name: Run Basic conformance tests with dynamic client registration run: | ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-basic-certification-test-plan[client_registration=dynamic_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-basic-dynamic-ci.json + - name: Switch RP App to RP-Initiated Logout flow + working-directory: ./main + run: | + LOGOUT_FLOW=rp_initiated docker compose -f docker/docker-compose.yml up -d + sleep 5 + - name: Restart trigger-client daemon for RP-Initiated Logout tests + run: | + pkill -f trigger-client.py || true + python3 ./main/conformance-tests/trigger-client.py & + sleep 2 + - name: Run RP-Initiated Logout conformance tests + run: | + ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-rp-initiated-logout-rp-basic[client_registration=static_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-rp-logout-ci.json + - name: Switch RP App to RP-Initiated Logout flow with dynamic client registration + working-directory: ./main + run: | + CLIENT_REGISTRATION=dynamic_client LOGOUT_FLOW=rp_initiated docker compose -f docker/docker-compose.yml up -d + sleep 5 + - name: Restart trigger-client daemon for dynamic RP-Initiated Logout tests + run: | + pkill -f trigger-client.py || true + python3 ./main/conformance-tests/trigger-client.py & + sleep 2 + - name: Run RP-Initiated Logout conformance tests with dynamic client registration + run: | + ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-rp-initiated-logout-rp-basic[client_registration=dynamic_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-rp-logout-dynamic-ci.json - name: Stop RP App if: always() working-directory: ./main diff --git a/.gitignore b/.gitignore index 583270e..5d177b3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ composer.lock /.idea/ /.scannerwork/ -sonar-project.properties \ No newline at end of file +sonar-project.properties +__pycache__/ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6dbf83e..1a0800d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,6 +14,10 @@ services: - CLIENT_SECRET=oidc-client-php-test-secret - REDIRECT_URI=https://rp.local.conformance.test/callback - SCOPE=openid profile email + # 'none' runs plain login flows; 'rp_initiated' continues each login + # with an RP-Initiated Logout flow (for the RP logout conformance plan). + - LOGOUT_FLOW=${LOGOUT_FLOW:-none} + - POST_LOGOUT_REDIRECT_URI=https://rp.local.conformance.test/logout-callback extra_hosts: - "localhost.emobix.co.uk:host-gateway" diff --git a/docker/rp-app/index.php b/docker/rp-app/index.php index a42b64b..89d27ec 100644 --- a/docker/rp-app/index.php +++ b/docker/rp-app/index.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/vendor/autoload.php'; use Cicnavi\Oidc\DynamicallyRegisteredClient; +use Cicnavi\Oidc\Exceptions\OidcClientException; use Cicnavi\Oidc\PreRegisteredClient; use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; use GuzzleHttp\Client as GuzzleClient; @@ -19,8 +20,13 @@ $clientRegistration = getenv('CLIENT_REGISTRATION') ?: 'static_client'; $clientId = getenv('CLIENT_ID') ?: 'oidc-client-php-test'; $clientSecret = getenv('CLIENT_SECRET') ?: 'oidc-client-php-test-secret'; -$redirectUri = getenv('REDIRECT_URI') ?: 'https://rp.local.conformance.test/callback'; +$rpBaseUri = getenv('RP_BASE_URI') ?: 'https://rp.local.conformance.test'; +$redirectUri = getenv('REDIRECT_URI') ?: $rpBaseUri . '/callback'; $scope = getenv('SCOPE') ?: 'openid'; +// 'none' runs plain login flows; 'rp_initiated' continues each login with an +// RP-Initiated Logout flow (for the RP-Initiated Logout conformance plan). +$logoutFlow = getenv('LOGOUT_FLOW') ?: 'none'; +$postLogoutRedirectUri = getenv('POST_LOGOUT_REDIRECT_URI') ?: $rpBaseUri . '/logout-callback'; try { // Disable SSL verification for internal Guzzle client because conformance-suite uses a self-signed cert @@ -33,7 +39,8 @@ scope: $scope, clientName: 'oidc-client-php', httpClient: $httpClient, - defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::Query + defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::Query, + postLogoutRedirectUris: $logoutFlow === 'rp_initiated' ? [$postLogoutRedirectUri] : [], ); } else { $client = new PreRegisteredClient( @@ -53,11 +60,40 @@ // Exchange authorization code for token and fetch user data $userData = $client->getUserData(); + if ($logoutFlow === 'rp_initiated') { + // Continue with RP-Initiated Logout in a separate request, so the + // persisted login data (ID token for 'id_token_hint') is read + // from the session store the way a real application would. + header('Location: ' . $rpBaseUri . '/logout', true, 302); + exit; + } + // Print success div for automated browser/curl matching echo 'OIDC RP Test Completion'; echo '
OIDC Flow Successful!
'; echo '

User Data

' . htmlspecialchars(json_encode($userData, JSON_PRETTY_PRINT)) . '
'; echo ''; + } elseif ($path === '/logout') { + // Redirects the user agent to the OP's end_session_endpoint with + // id_token_hint, client_id, post_logout_redirect_uri and state. + $client->logout(postLogoutRedirectUri: $postLogoutRedirectUri); + } elseif ($path === '/logout-callback') { + try { + $client->validateLogoutCallback(); + + echo 'OIDC RP Logout Completion'; + echo '
RP-Initiated Logout Successful!
'; + echo '

Login data cleared: ' . ($client->getLoginData() === null ? 'yes' : 'NO') . '

'; + echo ''; + } catch (OidcClientException $exception) { + // Negative test modules (state omitted or changed by the OP) end + // up here: the RP must not treat the logout as confirmed. + http_response_code(400); + echo 'OIDC RP Logout Rejected'; + echo '
Post logout callback rejected, logout is NOT confirmed: ' + . htmlspecialchars($exception->getMessage()) . '
'; + echo ''; + } } else { if ($client instanceof DynamicallyRegisteredClient) { // Each conformance test module is a fresh OP instance served on the diff --git a/docs/5-Conformance-Testing.md b/docs/5-Conformance-Testing.md index 44a359d..0f21562 100644 --- a/docs/5-Conformance-Testing.md +++ b/docs/5-Conformance-Testing.md @@ -5,6 +5,8 @@ The `oidc-client-php` library has been fully tested and verified against the off Specifically, currently we run the following OpenID Conformance Tests: * **Basic RP profile** (`oidcc-client-basic-certification-test-plan` plan using static client registration and plain HTTP request authorization). * **Basic RP profile with dynamic client registration** (`oidcc-client-basic-certification-test-plan` plan using dynamic client registration and plain HTTP request authorization). +* **RP-Initiated Logout RP profile (Basic)** (`oidcc-client-rp-initiated-logout-rp-basic` plan using static client registration and plain HTTP request authorization). +* **RP-Initiated Logout RP profile (Basic) with dynamic client registration** (`oidcc-client-rp-initiated-logout-rp-basic` plan using dynamic client registration and plain HTTP request authorization). --- @@ -65,6 +67,19 @@ Since every conformance test module is a fresh OP instance served on the same is RP test application performs a new client registration each time an authorization flow is started (any previously persisted registration would be stale). +For the RP-Initiated Logout test plan, additionally set `LOGOUT_FLOW=rp_initiated`. The RP test +application then continues every completed login with an RP-Initiated Logout: the callback +redirects to `/logout` (which sends the logout request to the OP's `end_session_endpoint` with +`id_token_hint`, `client_id`, `post_logout_redirect_uri` and `state`), and the OP redirects back +to `/logout-callback`, where the state parameter is validated (an invalid or missing state is +rejected, as exercised by the negative test modules): + ```bash + LOGOUT_FLOW=rp_initiated docker compose -f docker/docker-compose.yml up --build -d + # ... or, with dynamic client registration (post_logout_redirect_uris is then + # registered on the OP's registration endpoint): + CLIENT_REGISTRATION=dynamic_client LOGOUT_FLOW=rp_initiated docker compose -f docker/docker-compose.yml up --build -d + ``` + --- ### Step 3: Run the Conformance Tests @@ -91,5 +106,23 @@ started (any previously persisted registration would be stale). "oidcc-client-basic-certification-test-plan[client_registration=dynamic_client][request_type=plain_http_request]" \ conformance-tests/conformance-basic-dynamic-ci.json ``` +4. For the RP-Initiated Logout plan (with the RP test application started using + `LOGOUT_FLOW=rp_initiated`, see Step 2), run: + ```bash + python3 /path/to/conformance-suite/scripts/run-test-plan.py \ + --expected-failures-file conformance-tests/basic-warnings.json \ + --expected-skips-file conformance-tests/basic-skips.json \ + "oidcc-client-rp-initiated-logout-rp-basic[client_registration=static_client][request_type=plain_http_request]" \ + conformance-tests/conformance-rp-logout-ci.json + ``` + or, for the dynamic client registration variant (RP test application started using + `CLIENT_REGISTRATION=dynamic_client LOGOUT_FLOW=rp_initiated`): + ```bash + python3 /path/to/conformance-suite/scripts/run-test-plan.py \ + --expected-failures-file conformance-tests/basic-warnings.json \ + --expected-skips-file conformance-tests/basic-skips.json \ + "oidcc-client-rp-initiated-logout-rp-basic[client_registration=dynamic_client][request_type=plain_http_request]" \ + conformance-tests/conformance-rp-logout-dynamic-ci.json + ``` All test modules should complete and pass cleanly. diff --git a/tests/Oidc/FederatedClientTest.php b/tests/Oidc/FederatedClientTest.php index a9b606d..a83250f 100644 --- a/tests/Oidc/FederatedClientTest.php +++ b/tests/Oidc/FederatedClientTest.php @@ -85,10 +85,16 @@ final class FederatedClientTest extends TestCase private MockObject $federationMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\Jwk + */ private \PHPUnit\Framework\MockObject\Stub $jwkMock; private HashAlgorithmsEnum $jwkThumbprintHashAlgo; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\ValueAbstracts\Factories\SignatureKeyPairFactory + */ private \PHPUnit\Framework\MockObject\Stub $signatureKeyPairFactoryMock; private MockObject $signatureKeyPairBagFactoryMock; @@ -107,12 +113,21 @@ final class FederatedClientTest extends TestCase private PkceCodeChallengeMethodEnum $pkceCodeChallengeMethod; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\Cicnavi\Oidc\DataStore\Interfaces\SessionStoreInterface + */ private \PHPUnit\Framework\MockObject\Stub $sessionStoreMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\GuzzleHttp\Client + */ private \PHPUnit\Framework\MockObject\Stub $httpClientMock; private MockObject $coreMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\Jwks + */ private \PHPUnit\Framework\MockObject\Stub $jwksMock; private MockObject $requestDataHandlerMock; diff --git a/tests/Oidc/Federation/EntityConfigTest.php b/tests/Oidc/Federation/EntityConfigTest.php index b11cb51..1ccc3e0 100644 --- a/tests/Oidc/Federation/EntityConfigTest.php +++ b/tests/Oidc/Federation/EntityConfigTest.php @@ -19,16 +19,34 @@ final class EntityConfigTest extends TestCase { private string $entityId; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\ValueAbstracts\TrustAnchorConfigBag + */ private \PHPUnit\Framework\MockObject\Stub $trustAnchorConfigBagMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\ValueAbstracts\UniqueStringBag + */ private \PHPUnit\Framework\MockObject\Stub $authorityHintBagMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfigBag + */ private \PHPUnit\Framework\MockObject\Stub $federationSignatureKeyPairConfigBagMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\ValueAbstracts\UniqueStringBag + */ private \PHPUnit\Framework\MockObject\Stub $staticTrustMarkBagMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\ValueAbstracts\KeyedStringBag + */ private \PHPUnit\Framework\MockObject\Stub $dynamicTrustMarkBagMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\ValueAbstracts\ClaimBag + */ private \PHPUnit\Framework\MockObject\Stub $additionalClaimBag; protected function setUp(): void diff --git a/tests/Oidc/Federation/RelyingPartyConfigTest.php b/tests/Oidc/Federation/RelyingPartyConfigTest.php index 1023683..ce6323f 100644 --- a/tests/Oidc/Federation/RelyingPartyConfigTest.php +++ b/tests/Oidc/Federation/RelyingPartyConfigTest.php @@ -17,12 +17,24 @@ #[CoversClass(RelyingPartyConfig::class)] final class RelyingPartyConfigTest extends TestCase { + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\ValueAbstracts\RedirectUriBag + */ private \PHPUnit\Framework\MockObject\Stub $redirectUriBagMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairConfigBag + */ private \PHPUnit\Framework\MockObject\Stub $connectSignatureKeyPairBagMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\ValueAbstracts\ScopeBag + */ private \PHPUnit\Framework\MockObject\Stub $scopeBagMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\ValueAbstracts\ClaimBag + */ private \PHPUnit\Framework\MockObject\Stub $additionalClaimBagMock; private string $initiateLoginUri; diff --git a/tests/Oidc/PreRegisteredClientTest.php b/tests/Oidc/PreRegisteredClientTest.php index 41cec8a..a8dc4cf 100644 --- a/tests/Oidc/PreRegisteredClientTest.php +++ b/tests/Oidc/PreRegisteredClientTest.php @@ -48,22 +48,43 @@ final class PreRegisteredClientTest extends TestCase private bool $fetchUserinfoClaims; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\SupportedAlgorithms + */ private \PHPUnit\Framework\MockObject\Stub $supportedAlgorithmsMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\SupportedSerializers + */ private \PHPUnit\Framework\MockObject\Stub $supportedSerializersMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\Psr\Log\LoggerInterface + */ private \PHPUnit\Framework\MockObject\Stub $loggerMock; private \PHPUnit\Framework\MockObject\MockObject $cacheMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\Cicnavi\Oidc\DataStore\Interfaces\SessionStoreInterface + */ private \PHPUnit\Framework\MockObject\Stub $sessionStoreMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\GuzzleHttp\Client + */ private \PHPUnit\Framework\MockObject\Stub $httpClientMock; private \PHPUnit\Framework\MockObject\MockObject $metadataMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\Core + */ private \PHPUnit\Framework\MockObject\Stub $coreMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\SimpleSAML\OpenID\Jwks + */ private \PHPUnit\Framework\MockObject\Stub $jwksMock; private \DateInterval $maxCacheDuration; diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index 24d25b7..5036313 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -44,6 +44,9 @@ final class RequestDataHandlerTest extends TestCase private MockObject $coreMock; + /** + * @var \PHPUnit\Framework\MockObject\Stub&\Psr\SimpleCache\CacheInterface + */ private \PHPUnit\Framework\MockObject\Stub $cacheMock; private MockObject $jwksMock; From 43a465bccff46612ffb63b15d9d487ea38bbfd23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 5 Jul 2026 11:48:43 +0200 Subject: [PATCH 09/10] Try conformance test fix --- .github/workflows/build.yml | 7 +++---- docs/5-Conformance-Testing.md | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a37e4b5..0a8287a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,8 +2,7 @@ name: Build on: push: - # TODO: remove 'logout' after the branch is merged. - branches: [ master, v3.x, logout ] + branches: [ master, v3.x ] pull_request: branches: [ master ] @@ -89,7 +88,7 @@ jobs: sleep 2 - name: Run RP-Initiated Logout conformance tests run: | - ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-rp-initiated-logout-rp-basic[client_registration=static_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-rp-logout-ci.json + ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-rp-initiated-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=static_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-rp-logout-ci.json - name: Switch RP App to RP-Initiated Logout flow with dynamic client registration working-directory: ./main run: | @@ -102,7 +101,7 @@ jobs: sleep 2 - name: Run RP-Initiated Logout conformance tests with dynamic client registration run: | - ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-rp-initiated-logout-rp-basic[client_registration=dynamic_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-rp-logout-dynamic-ci.json + ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-rp-initiated-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=dynamic_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-rp-logout-dynamic-ci.json - name: Stop RP App if: always() working-directory: ./main diff --git a/docs/5-Conformance-Testing.md b/docs/5-Conformance-Testing.md index 0f21562..6d457eb 100644 --- a/docs/5-Conformance-Testing.md +++ b/docs/5-Conformance-Testing.md @@ -112,7 +112,7 @@ rejected, as exercised by the negative test modules): python3 /path/to/conformance-suite/scripts/run-test-plan.py \ --expected-failures-file conformance-tests/basic-warnings.json \ --expected-skips-file conformance-tests/basic-skips.json \ - "oidcc-client-rp-initiated-logout-rp-basic[client_registration=static_client][request_type=plain_http_request]" \ + "oidcc-client-rp-initiated-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=static_client][request_type=plain_http_request]" \ conformance-tests/conformance-rp-logout-ci.json ``` or, for the dynamic client registration variant (RP test application started using @@ -121,7 +121,7 @@ rejected, as exercised by the negative test modules): python3 /path/to/conformance-suite/scripts/run-test-plan.py \ --expected-failures-file conformance-tests/basic-warnings.json \ --expected-skips-file conformance-tests/basic-skips.json \ - "oidcc-client-rp-initiated-logout-rp-basic[client_registration=dynamic_client][request_type=plain_http_request]" \ + "oidcc-client-rp-initiated-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=dynamic_client][request_type=plain_http_request]" \ conformance-tests/conformance-rp-logout-dynamic-ci.json ``` From dc9a2396d2308ec3d316556039a727781a779d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 5 Jul 2026 12:16:34 +0200 Subject: [PATCH 10/10] Fix conformance tests --- .github/workflows/build.yml | 4 ++-- .../conformance-rp-logout-ci.json | 1 + docker/docker-compose.yml | 1 + docker/rp-app/index.php | 24 +++++++++++++++++-- docs/5-Conformance-Testing.md | 17 +++++++++---- 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a8287a..70ba98e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -88,7 +88,7 @@ jobs: sleep 2 - name: Run RP-Initiated Logout conformance tests run: | - ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-rp-initiated-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=static_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-rp-logout-ci.json + ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json "oidcc-client-rp-initiated-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=static_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-rp-logout-ci.json - name: Switch RP App to RP-Initiated Logout flow with dynamic client registration working-directory: ./main run: | @@ -101,7 +101,7 @@ jobs: sleep 2 - name: Run RP-Initiated Logout conformance tests with dynamic client registration run: | - ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json --expected-skips-file ./main/conformance-tests/basic-skips.json "oidcc-client-rp-initiated-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=dynamic_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-rp-logout-dynamic-ci.json + ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json "oidcc-client-rp-initiated-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=dynamic_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-rp-logout-dynamic-ci.json - name: Stop RP App if: always() working-directory: ./main diff --git a/conformance-tests/conformance-rp-logout-ci.json b/conformance-tests/conformance-rp-logout-ci.json index b22525a..f16ed3b 100644 --- a/conformance-tests/conformance-rp-logout-ci.json +++ b/conformance-tests/conformance-rp-logout-ci.json @@ -8,6 +8,7 @@ "post_logout_redirect_uris": [ "https://rp.local.conformance.test/logout-callback" ], + "backchannel_logout_uri": "https://rp.local.conformance.test/backchannel-logout", "request_type": "plain_http_request" }, "browser": [ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1a0800d..6afde00 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -18,6 +18,7 @@ services: # with an RP-Initiated Logout flow (for the RP logout conformance plan). - LOGOUT_FLOW=${LOGOUT_FLOW:-none} - POST_LOGOUT_REDIRECT_URI=https://rp.local.conformance.test/logout-callback + - BACKCHANNEL_LOGOUT_URI=https://rp.local.conformance.test/backchannel-logout extra_hosts: - "localhost.emobix.co.uk:host-gateway" diff --git a/docker/rp-app/index.php b/docker/rp-app/index.php index 89d27ec..95ba0bc 100644 --- a/docker/rp-app/index.php +++ b/docker/rp-app/index.php @@ -27,8 +27,26 @@ // RP-Initiated Logout flow (for the RP-Initiated Logout conformance plan). $logoutFlow = getenv('LOGOUT_FLOW') ?: 'none'; $postLogoutRedirectUri = getenv('POST_LOGOUT_REDIRECT_URI') ?: $rpBaseUri . '/logout-callback'; +$backchannelLogoutUri = getenv('BACKCHANNEL_LOGOUT_URI') ?: $rpBaseUri . '/backchannel-logout'; try { + $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + + if ($path === '/backchannel-logout') { + // Back-Channel Logout endpoint stub. The RP-Initiated Logout test + // modules require the client to have a backchannel_logout_uri or + // frontchannel_logout_uri registered, and the suite POSTs a logout + // token here while handling the end_session request. Proper logout + // token validation is a separate library feature (Back-Channel + // Logout support) - until it lands, only acknowledge the request. + // 'Cache-Control: no-store' is required per OIDC Back-Channel Logout + // 2.8 (the suite warns when missing). + header('Cache-Control: no-store'); + http_response_code(200); + echo 'OK'; + exit; + } + // Disable SSL verification for internal Guzzle client because conformance-suite uses a self-signed cert $httpClient = new GuzzleClient(['verify' => false]); @@ -41,6 +59,10 @@ httpClient: $httpClient, defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::Query, postLogoutRedirectUris: $logoutFlow === 'rp_initiated' ? [$postLogoutRedirectUri] : [], + // See the /backchannel-logout endpoint stub above. + additionalClientMetadata: $logoutFlow === 'rp_initiated' + ? ['backchannel_logout_uri' => $backchannelLogoutUri] + : [], ); } else { $client = new PreRegisteredClient( @@ -54,8 +76,6 @@ ); } - $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); - if ($path === '/callback') { // Exchange authorization code for token and fetch user data $userData = $client->getUserData(); diff --git a/docs/5-Conformance-Testing.md b/docs/5-Conformance-Testing.md index 6d457eb..43d70bf 100644 --- a/docs/5-Conformance-Testing.md +++ b/docs/5-Conformance-Testing.md @@ -72,7 +72,17 @@ application then continues every completed login with an RP-Initiated Logout: th redirects to `/logout` (which sends the logout request to the OP's `end_session_endpoint` with `id_token_hint`, `client_id`, `post_logout_redirect_uri` and `state`), and the OP redirects back to `/logout-callback`, where the state parameter is validated (an invalid or missing state is -rejected, as exercised by the negative test modules): +rejected, as exercised by the negative test modules). + +Note that the RP-Initiated Logout test modules require the client to also have a +`backchannel_logout_uri` or `frontchannel_logout_uri` registered (condition +`EnsureClientHasAtLeastOneOfBackOrFrontChannelLogoutUri`), and the suite sends a Back-Channel +Logout request to it while handling the `end_session_endpoint` request. The RP test application +therefore exposes a `/backchannel-logout` endpoint (currently a stub which acknowledges the +request with `Cache-Control: no-store`, until Back-Channel Logout support lands in the library). +It is registered statically via `backchannel_logout_uri` in +`conformance-tests/conformance-rp-logout-ci.json`, and dynamically via the +`DynamicallyRegisteredClient` `additionalClientMetadata` constructor parameter: ```bash LOGOUT_FLOW=rp_initiated docker compose -f docker/docker-compose.yml up --build -d # ... or, with dynamic client registration (post_logout_redirect_uris is then @@ -107,11 +117,11 @@ rejected, as exercised by the negative test modules): conformance-tests/conformance-basic-dynamic-ci.json ``` 4. For the RP-Initiated Logout plan (with the RP test application started using - `LOGOUT_FLOW=rp_initiated`, see Step 2), run: + `LOGOUT_FLOW=rp_initiated`, see Step 2), run (without the `--expected-skips-file` option, + since the skipped test module only exists in the Basic plan): ```bash python3 /path/to/conformance-suite/scripts/run-test-plan.py \ --expected-failures-file conformance-tests/basic-warnings.json \ - --expected-skips-file conformance-tests/basic-skips.json \ "oidcc-client-rp-initiated-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=static_client][request_type=plain_http_request]" \ conformance-tests/conformance-rp-logout-ci.json ``` @@ -120,7 +130,6 @@ rejected, as exercised by the negative test modules): ```bash python3 /path/to/conformance-suite/scripts/run-test-plan.py \ --expected-failures-file conformance-tests/basic-warnings.json \ - --expected-skips-file conformance-tests/basic-skips.json \ "oidcc-client-rp-initiated-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=dynamic_client][request_type=plain_http_request]" \ conformance-tests/conformance-rp-logout-dynamic-ci.json ```