From 3d0c229887ce5023aa0bbef692c88cb2c0349a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 5 Jul 2026 16:42:40 +0200 Subject: [PATCH 01/10] Initial BC Logout implementation --- .github/workflows/build.yml | 26 + README.md | 6 +- .../conformance-backchannel-logout-ci.json | 28 + ...ormance-backchannel-logout-dynamic-ci.json | 18 + docker/rp-app/index.php | 35 +- docs/2-Pre-Registered-Client.md | 59 +++ docs/3-Federated-Client.md | 23 + docs/4-Dynamically-Registered-Client.md | 37 +- docs/5-Conformance-Testing.md | 33 +- src/DynamicallyRegisteredClient.php | 106 ++++ src/FederatedClient.php | 119 +++++ src/Helpers/HttpHelper.php | 52 ++ src/Logout/CacheLoginRevocationRegistry.php | 119 +++++ .../LoginRevocationRegistryInterface.php | 72 +++ src/PreRegisteredClient.php | 59 +++ src/Protocol/RequestDataHandler.php | 329 +++++++++++- .../Oidc/DynamicallyRegisteredClientTest.php | 157 ++++++ tests/Oidc/FederatedClientTest.php | 145 +++++ tests/Oidc/Helpers/HttpHelperTest.php | 59 +++ .../CacheLoginRevocationRegistryTest.php | 152 ++++++ tests/Oidc/PreRegisteredClientTest.php | 89 ++++ .../Oidc/Protocol/RequestDataHandlerTest.php | 496 ++++++++++++++++-- 22 files changed, 2149 insertions(+), 70 deletions(-) create mode 100644 conformance-tests/conformance-backchannel-logout-ci.json create mode 100644 conformance-tests/conformance-backchannel-logout-dynamic-ci.json create mode 100644 src/Logout/CacheLoginRevocationRegistry.php create mode 100644 src/Logout/Interfaces/LoginRevocationRegistryInterface.php create mode 100644 tests/Oidc/Logout/CacheLoginRevocationRegistryTest.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 70ba98e..6b1cee2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -102,6 +102,32 @@ jobs: - 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 "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: Switch RP App back to static client for Back-Channel Logout tests + 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 Back-Channel Logout tests + run: | + pkill -f trigger-client.py || true + python3 ./main/conformance-tests/trigger-client.py & + sleep 2 + - name: Run Back-Channel Logout conformance tests + run: | + ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json "oidcc-client-back-channel-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=static_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-backchannel-logout-ci.json + - name: Switch RP App to Back-Channel Logout 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 Back-Channel Logout tests + run: | + pkill -f trigger-client.py || true + python3 ./main/conformance-tests/trigger-client.py & + sleep 2 + - name: Run Back-Channel Logout conformance tests with dynamic client registration + run: | + ./conformance-suite/scripts/run-test-plan.py --expected-failures-file ./main/conformance-tests/basic-warnings.json "oidcc-client-back-channel-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=dynamic_client][request_type=plain_http_request]" ./main/conformance-tests/conformance-backchannel-logout-dynamic-ci.json - name: Stop RP App if: always() working-directory: ./main diff --git a/README.md b/README.md index eb118ce..0f72b6f 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, OIDC RP-Initiated Logout, 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, OIDC Back-Channel +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/conformance-tests/conformance-backchannel-logout-ci.json b/conformance-tests/conformance-backchannel-logout-ci.json new file mode 100644 index 0000000..2416c58 --- /dev/null +++ b/conformance-tests/conformance-backchannel-logout-ci.json @@ -0,0 +1,28 @@ +{ + "alias": "oidc-client-php", + "description": "OIDC Back-Channel 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" + ], + "backchannel_logout_uri": "https://rp.local.conformance.test/backchannel-logout", + "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-backchannel-logout-dynamic-ci.json b/conformance-tests/conformance-backchannel-logout-dynamic-ci.json new file mode 100644 index 0000000..43d9ddf --- /dev/null +++ b/conformance-tests/conformance-backchannel-logout-dynamic-ci.json @@ -0,0 +1,18 @@ +{ + "alias": "oidc-client-php", + "description": "OIDC Back-Channel 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/docker/rp-app/index.php b/docker/rp-app/index.php index 95ba0bc..be79c42 100644 --- a/docker/rp-app/index.php +++ b/docker/rp-app/index.php @@ -32,21 +32,6 @@ 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]); @@ -59,10 +44,11 @@ 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] - : [], + // Registered so the OP can deliver Back-Channel Logout requests + // to the /backchannel-logout endpoint below (the logout test + // modules also require the client to have a + // backchannel_logout_uri or frontchannel_logout_uri). + backchannelLogoutUri: $logoutFlow === 'rp_initiated' ? $backchannelLogoutUri : null, ); } else { $client = new PreRegisteredClient( @@ -76,6 +62,17 @@ ); } + if ($path === '/backchannel-logout') { + // OIDC Back-Channel Logout endpoint. The conformance suite POSTs a + // logout token here while handling the end_session request. The + // handler validates the logout token, records the login revocation, + // and emits the spec-compliant response itself (200 when the logout + // was performed, 400 with a JSON error body when the logout token + // was rejected, 'Cache-Control: no-store' in both cases). + $client->handleBackchannelLogoutRequest(); + exit; + } + if ($path === '/callback') { // Exchange authorization code for token and fetch user data $userData = $client->getUserData(); diff --git a/docs/2-Pre-Registered-Client.md b/docs/2-Pre-Registered-Client.md index 56300d3..9c133c9 100644 --- a/docs/2-Pre-Registered-Client.md +++ b/docs/2-Pre-Registered-Client.md @@ -285,6 +285,65 @@ 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. +## Back-Channel Logout + +The client can also act as the receiving side of +[OpenID Connect Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html): +the OP notifies the client about a logout (for example, initiated at the OP +or at another client of the same End-User session) by sending a logout +token directly to a dedicated client endpoint, outside of the user agent. + +To use it, register an endpoint URI as the `backchannel_logout_uri` client +metadata on the OP, and on that endpoint call +`handleBackchannelLogoutRequest()`: + +```php +use Cicnavi\Oidc\PreRegisteredClient; +/** @var PreRegisteredClient $oidcClient */ + +// File: backchannel-logout.php (registered as 'backchannel_logout_uri') +$oidcClient->handleBackchannelLogoutRequest(); +``` + +The method validates the logout token from the request (signature against +the OP JWKS, issuer, audience, required claims, freshness, and `jti` replay +detection), records the requested login revocation, and emits the proper +HTTP response itself (200 when the logout was performed, 400 with a JSON +error body when not). It also accepts an optional PSR-7 server request to +read from, and an optional PSR-7 `response` instance which will be +populated and returned instead of emitting output directly. + +### How the affected login is terminated + +A back-channel logout request arrives outside the context of the End-User's +session (no session cookie is sent with it), so the client can not remove +the affected login data from its session store directly. Instead, the +revocation is recorded in a login revocation registry, which is backed by +the shared client cache by default (the same file-based cache used for OP +configuration and JWKS content - shared between requests). + +Logins are matched against recorded revocations whenever the persisted +login data is read (`getLoginData()`, `getIdToken()`...): a revoked login +is cleared from the session store (local logout) and observed as absent. A +logout token with a `sid` claim only terminates the login of that +particular OP session, while a logout token with only a `sub` claim +terminates all of that subject's logins (established before the +revocation). + +For the application this means: if you use `getLoginData() !== null` (or +similar) as your "user is logged in via OIDC" check on each request, logins +terminated via Back-Channel Logout are picked up automatically. If you keep +your own application session state independent of the login data, check the +login data on each request and end your application session when it is +gone. Note that the client does not (and can not) destroy the PHP session +of the affected user itself. + +If you use a custom cache or session setup, make sure the cache passed to +the client is shared between requests - otherwise revocations recorded on +the back-channel logout endpoint would not be observable in user sessions. +A custom registry implementation can be provided via the +`RequestDataHandler` constructor (`loginRevocationRegistry` parameter). + ## 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 91cb009..cfdb903 100644 --- a/docs/3-Federated-Client.md +++ b/docs/3-Federated-Client.md @@ -180,6 +180,29 @@ request using `validateLogoutCallback()` (verifies the returned `state`): $client->validateLogoutCallback(); ``` +### 5. Back-Channel Logout + +Back-Channel Logout is also available, same as for the +[Pre-Registered Client](2-Pre-Registered-Client.md#back-channel-logout): +call `handleBackchannelLogoutRequest()` on the endpoint which receives +back-channel logout requests from OPs. Register that endpoint as the +`backchannel_logout_uri` claim in this RP's OpenID Relying Party federation +metadata (which can be provided using the Relying Party configuration +additional claims). + +```php +/** @var \Cicnavi\Oidc\FederatedClient $client */ + +// File: backchannel-logout.php (published as 'backchannel_logout_uri') +$client->handleBackchannelLogoutRequest(); +``` + +Since the OP is not known upfront, the issuing OP is determined from the +logout token `iss` claim, and trusted only if a federation Trust Chain can +be resolved from it to one of the configured Trust Anchors. The OP JWKS +used to verify the logout token signature is taken from the resolved OP +metadata. + ## 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 8a775c2..605caff 100644 --- a/docs/4-Dynamically-Registered-Client.md +++ b/docs/4-Dynamically-Registered-Client.md @@ -107,7 +107,11 @@ prepared from the constructor parameters: parameter), * `post_logout_redirect_uris` - if provided using the `postLogoutRedirectUris` parameter (used for RP-Initiated Logout, see -below). +below), +* `backchannel_logout_uri` (and optionally +`backchannel_logout_session_required`) - if provided using the +`backchannelLogoutUri` / `backchannelLogoutSessionRequired` parameters +(used for Back-Channel Logout, see below). Any additional client metadata claims can be provided using the `additionalClientMetadata` parameter. Claims provided here override the @@ -205,6 +209,37 @@ Note that providing `postLogoutRedirectUris` changes the client metadata set, so an existing client registration will be updated (or replaced) accordingly. +### Back-Channel Logout + +Back-Channel Logout is also available, same as for the +[Pre-Registered Client](2-Pre-Registered-Client.md#back-channel-logout): +call `handleBackchannelLogoutRequest()` on the endpoint which receives +back-channel logout requests from the OP. Register that endpoint as the +`backchannel_logout_uri` client metadata using the `backchannelLogoutUri` +constructor parameter (and optionally register +`backchannel_logout_session_required` using the +`backchannelLogoutSessionRequired` 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', + backchannelLogoutUri: 'https://your-example.org/backchannel-logout', +); + +// File: backchannel-logout.php +$oidcClient->handleBackchannelLogoutRequest(); +``` + +The logout token audience is validated against the client ID of the +persisted client registration - no client registration is performed or +updated while handling back-channel logout requests. Note that providing +`backchannelLogoutUri` 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/docs/5-Conformance-Testing.md b/docs/5-Conformance-Testing.md index 43d70bf..ada3b83 100644 --- a/docs/5-Conformance-Testing.md +++ b/docs/5-Conformance-Testing.md @@ -7,6 +7,8 @@ Specifically, currently we run the following OpenID Conformance Tests: * **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). +* **Back-Channel Logout RP profile (Basic)** (`oidcc-client-back-channel-logout-rp-basic` plan using static client registration and plain HTTP request authorization). +* **Back-Channel Logout RP profile (Basic) with dynamic client registration** (`oidcc-client-back-channel-logout-rp-basic` plan using dynamic client registration and plain HTTP request authorization). --- @@ -78,11 +80,12 @@ 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: +therefore exposes a `/backchannel-logout` endpoint, which uses the library's +`handleBackchannelLogoutRequest()` to validate the logout token, record the login revocation, +and respond (200 when the logout was performed, 400 when the logout token was rejected - as +exercised by the negative Back-Channel Logout test modules). It is registered statically via +`backchannel_logout_uri` in the conformance test configuration JSON files, and dynamically via +the `DynamicallyRegisteredClient` `backchannelLogoutUri` 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 @@ -90,6 +93,10 @@ It is registered statically via `backchannel_logout_uri` in CLIENT_REGISTRATION=dynamic_client LOGOUT_FLOW=rp_initiated docker compose -f docker/docker-compose.yml up --build -d ``` +The Back-Channel Logout test plan uses the same RP flow as the RP-Initiated Logout plan (the +suite delivers the logout token while handling the `end_session_endpoint` request), so the RP +test application is started the same way (`LOGOUT_FLOW=rp_initiated`) for both plans. + --- ### Step 3: Run the Conformance Tests @@ -133,5 +140,21 @@ It is registered statically via `backchannel_logout_uri` in "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 ``` +5. For the Back-Channel Logout plan (with the RP test application started using + `LOGOUT_FLOW=rp_initiated`, same as for the RP-Initiated Logout plan), run: + ```bash + python3 /path/to/conformance-suite/scripts/run-test-plan.py \ + --expected-failures-file conformance-tests/basic-warnings.json \ + "oidcc-client-back-channel-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=static_client][request_type=plain_http_request]" \ + conformance-tests/conformance-backchannel-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 \ + "oidcc-client-back-channel-logout-rp-basic[client_auth_type=client_secret_basic][client_registration=dynamic_client][request_type=plain_http_request]" \ + conformance-tests/conformance-backchannel-logout-dynamic-ci.json + ``` All test modules should complete and pass cleanly. diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php index 23eb93c..a060160 100644 --- a/src/DynamicallyRegisteredClient.php +++ b/src/DynamicallyRegisteredClient.php @@ -145,6 +145,17 @@ class DynamicallyRegisteredClient * 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. + * @param ?string $backchannelLogoutUri URI to register as + * 'backchannel_logout_uri' during client registration - the endpoint on + * which this client handles OIDC Back-Channel Logout requests from the + * OP (see handleBackchannelLogoutRequest()). Note that providing this + * changes the client metadata set, so an existing registration will be + * updated (or replaced) accordingly. + * @param ?bool $backchannelLogoutSessionRequired Value to register as + * 'backchannel_logout_session_required' during client registration + * (whether the OP should include a 'sid' claim in logout tokens sent to + * this client), null to leave it out. Only registered when + * $backchannelLogoutUri is provided. * * For other parameters, refer to PreRegisteredClient - they are forwarded * to the underlying client instance which is built after registration. @@ -197,6 +208,8 @@ public function __construct( ?ClientRegistrationHandler $registrationHandler = null, protected ?PreRegisteredClient $preRegisteredClient = null, protected readonly array $postLogoutRedirectUris = [], + protected readonly ?string $backchannelLogoutUri = null, + protected readonly ?bool $backchannelLogoutSessionRequired = null, ) { $this->cache = $cache ?? new FileCache( 'odrcpc-' . md5($this->opConfigurationUrl . '|' . $this->redirectUri), @@ -272,6 +285,27 @@ protected function validateClientMetadata(array $clientMetadata): void $postLogoutRedirectUri, ); } + + if (is_string($this->backchannelLogoutUri)) { + if ($this->backchannelLogoutUri === '') { + throw new OidcClientException( + 'Backchannel logout URI must be a non-empty string.', + ); + } + + $backchannelLogoutUriOverride = $clientMetadata[ClaimsEnum::BackChannelLogoutUri->value] ?? null; + + if ( + $backchannelLogoutUriOverride !== null && + $backchannelLogoutUriOverride !== $this->backchannelLogoutUri + ) { + throw new OidcClientException(sprintf( + 'Client metadata claim "%s" must match the configured backchannel logout URI "%s".', + ClaimsEnum::BackChannelLogoutUri->value, + $this->backchannelLogoutUri, + )); + } + } } /** @@ -741,6 +775,69 @@ public function validateLogoutCallback(?ServerRequestInterface $request = null): $this->resolveRequestDataHandler()->validateLogoutCallbackResponse($request, $this->useState); } + /** + * Handle an OIDC Back-Channel Logout request from the OP: validate the + * logout token from the request, record the login revocation it + * requests, and deliver the appropriate HTTP response (200 when the + * logout was performed, 400 with a JSON error body when not). Register + * the URI of the endpoint calling this method as the + * 'backchannel_logout_uri' client metadata (see the $backchannelLogoutUri + * constructor parameter). + * + * The logout token audience is validated against the client ID of the + * persisted client registration - no client registration is performed + * or updated here. Without a persisted registration the logout token + * can not be validated, so the request is rejected. + * + * For notes on how the revocation terminates the affected login, refer + * to PreRegisteredClient::handleBackchannelLogoutRequest(). + * + * @see PreRegisteredClient::handleBackchannelLogoutRequest() + */ + public function handleBackchannelLogoutRequest( + ?ServerRequestInterface $request = null, + ?ResponseInterface $response = null, + ): ?ResponseInterface { + $requestDataHandler = $this->resolveRequestDataHandler(); + + try { + $logoutToken = $requestDataHandler->parseBackchannelLogoutRequest($request); + + if (!is_string($opJwksUri = $this->metadata->get(ClaimsEnum::JwksUri->value))) { + throw new OidcClientException('JWKS URI not found in OP metadata.'); + } + + $clientId = $this->loadRegistrationData()?->getClientId(); + + if (!is_string($clientId) || $clientId === '') { + throw new OidcClientException( + 'No persisted client registration found, so the logout token audience can not be validated.', + ); + } + + $logoutTokenJws = $requestDataHandler->validateLogoutToken( + logoutToken: $logoutToken, + jwksUri: $opJwksUri, + expectedIssuer: $this->getOptionalMetadataString(ClaimsEnum::Issuer->value), + expectedClientId: $clientId, + ); + + $requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); + } catch (Throwable $throwable) { + $this->logger?->error('Back-channel logout request error. ' . $throwable->getMessage()); + + return HttpHelper::dispatchBackchannelLogoutResponse( + $response, + $throwable->getMessage(), + $this->logger, + ); + } + + $this->logger?->debug('Back-channel logout performed.'); + + return HttpHelper::dispatchBackchannelLogoutResponse($response, null, $this->logger); + } + /** * Raw ID token received at the last successful login, or null when not * available. Read from the session store - no client registration is @@ -866,6 +963,15 @@ public function buildClientRegistrationMetadata(): array ); } + if (is_string($this->backchannelLogoutUri)) { + $clientMetadata[ClaimsEnum::BackChannelLogoutUri->value] = $this->backchannelLogoutUri; + + if (is_bool($this->backchannelLogoutSessionRequired)) { + $clientMetadata[ClaimsEnum::BackChannelLogoutSessionRequired->value] = + $this->backchannelLogoutSessionRequired; + } + } + return array_merge($clientMetadata, $this->additionalClientMetadata); } diff --git a/src/FederatedClient.php b/src/FederatedClient.php index 8b2f0ad..d106db8 100644 --- a/src/FederatedClient.php +++ b/src/FederatedClient.php @@ -1020,6 +1020,125 @@ public function validateLogoutCallback(?ServerRequestInterface $request = null): $this->requestDataHandler->validateLogoutCallbackResponse($request); } + /** + * Handle an OIDC Back-Channel Logout request from an OP: validate the + * logout token from the request, record the login revocation it + * requests, and deliver the appropriate HTTP response (200 when the + * logout was performed, 400 with a JSON error body when not). Register + * the URI of the endpoint calling this method as the + * 'backchannel_logout_uri' claim in this RP's OpenID Relying Party + * federation metadata (see RelyingPartyConfig additional claims). + * + * The issuing OP is determined from the logout token 'iss' claim and + * trusted only if a federation Trust Chain can be resolved from it to + * one of the configured Trust Anchors - the OP JWKS URI used for logout + * token signature verification is taken from the resolved OP metadata. + * + * For notes on how the revocation terminates the affected login, refer + * to PreRegisteredClient::handleBackchannelLogoutRequest(). + * + * @see PreRegisteredClient::handleBackchannelLogoutRequest() + */ + public function handleBackchannelLogoutRequest( + ?ServerRequestInterface $request = null, + ?ResponseInterface $response = null, + ): ?ResponseInterface { + try { + $logoutToken = $this->requestDataHandler->parseBackchannelLogoutRequest($request); + + // Build the logout token (this validates the claim set, but not + // the signature) to learn which OP issued it. + $issuer = $this->core->logoutTokenFactory()->fromToken($logoutToken)->getIssuer(); + + if ($issuer === '') { + throw new OidcClientException('Logout token issuer claim is empty.'); + } + + $opResolvedMetadata = $this->resolveOpMetadata($issuer); + + $opJwksUri = $opResolvedMetadata[ClaimsEnum::JwksUri->value] ?? null; + + if (!is_string($opJwksUri)) { + throw new OidcClientException('OpenID Provider JWKS URI not available in resolved metadata.'); + } + + $logoutTokenJws = $this->requestDataHandler->validateLogoutToken( + logoutToken: $logoutToken, + jwksUri: $opJwksUri, + expectedIssuer: $issuer, + expectedClientId: $this->entityConfig->getEntityId(), + ); + + $this->requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); + } catch (\Throwable $throwable) { + $this->logger?->error('Back-channel logout request error. ' . $throwable->getMessage()); + + return HttpHelper::dispatchBackchannelLogoutResponse( + $response, + $throwable->getMessage(), + $this->logger, + ); + } + + $this->logger?->debug('Back-channel logout performed.'); + + return HttpHelper::dispatchBackchannelLogoutResponse($response, null, $this->logger); + } + + /** + * Resolve OpenID Provider metadata for the given OP entity ID by + * resolving a federation Trust Chain to it (using the configured Trust + * Anchors) and reading the resolved OpenID Provider metadata from the + * shortest chain. + * + * @param non-empty-string $opEntityId OpenID Provider Entity Identifier. + * @return array Resolved OpenID Provider metadata. + * @throws OidcClientException If no Trust Chain could be resolved or the + * chain does not contain OpenID Provider metadata. + */ + protected function resolveOpMetadata(string $opEntityId): array + { + $validTrustAnchorIds = $this->entityConfig->getTrustAnchorBag()->getAllEntityIds(); + + if ($validTrustAnchorIds === []) { + $this->logger?->error('No valid Trust Anchors configured for the client.'); + throw new OidcClientException('No valid Trust Anchors configured for the client.'); + } + + try { + $opTrustChainBag = $this->federation->trustChainResolver()->for( + $opEntityId, + $validTrustAnchorIds, + ); + } catch (TrustChainException $trustChainException) { + $this->logger?->error( + 'Error resolving Trust Chain to OP: ' . $trustChainException->getMessage(), + [ + 'openIdProviderId' => $opEntityId, + 'entityId' => $this->entityConfig->getEntityId(), + ], + ); + throw new OidcClientException( + $trustChainException->getMessage(), + $trustChainException->getCode(), + $trustChainException, + ); + } + + $opResolvedMetadata = $opTrustChainBag->getShortest() + ->getResolvedMetadata(EntityTypesEnum::OpenIdProvider); + + if (!is_array($opResolvedMetadata)) { + $this->logger?->error( + 'OpenID Provider resolved metadata not available.', + ['entityId' => $opEntityId], + ); + throw new OidcClientException('OpenID Provider resolved metadata not available.'); + } + + return $opResolvedMetadata; + } + /** * 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 diff --git a/src/Helpers/HttpHelper.php b/src/Helpers/HttpHelper.php index ea64bc2..8a58173 100644 --- a/src/Helpers/HttpHelper.php +++ b/src/Helpers/HttpHelper.php @@ -171,4 +171,56 @@ public static function dispatchFrontChannelRequest( header('Location: ' . $uri); exit; } + + /** + * Deliver the HTTP response for an OIDC Back-Channel Logout request: + * HTTP 200 when the logout was performed, HTTP 400 with a JSON error + * body when the logout request or token was invalid (per specification + * sections 2.8 and 2.9), with 'Cache-Control: no-store' in both cases. + * + * If a PSR-7 response instance is provided, it is populated and + * returned. Otherwise, the response is emitted directly and the script + * is terminated. + * + * @param ?string $error Error description when the logout failed, null + * when the logout was performed. + */ + public static function dispatchBackchannelLogoutResponse( + ?ResponseInterface $response = null, + ?string $error = null, + ?LoggerInterface $logger = null, + ): ?ResponseInterface { + $statusCode = $error === null ? 200 : 400; + + $body = null; + if (is_string($error)) { + $logger?->debug('Returning back-channel logout error response.', ['error' => $error]); + $encodedBody = json_encode( + ['error' => 'invalid_request', 'error_description' => $error], + JSON_UNESCAPED_SLASHES, + ); + $body = is_string($encodedBody) ? $encodedBody : '{"error": "invalid_request"}'; + } + + if ($response instanceof ResponseInterface) { + $response = $response->withStatus($statusCode)->withHeader('Cache-Control', 'no-store'); + + if (is_string($body)) { + $response->getBody()->write($body); + $response = $response->withHeader('Content-Type', 'application/json'); + } + + return $response; + } + + http_response_code($statusCode); + header('Cache-Control: no-store'); + + if (is_string($body)) { + header('Content-Type: application/json'); + echo $body; + } + + exit; + } } diff --git a/src/Logout/CacheLoginRevocationRegistry.php b/src/Logout/CacheLoginRevocationRegistry.php new file mode 100644 index 0000000..1c96d5a --- /dev/null +++ b/src/Logout/CacheLoginRevocationRegistry.php @@ -0,0 +1,119 @@ +recordRevocation(self::KEY_PREFIX_SESSION, $issuer, $sessionId); + } + + public function revokeSubject(string $issuer, string $subject): void + { + $this->recordRevocation(self::KEY_PREFIX_SUBJECT, $issuer, $subject); + } + + public function isSessionRevoked(string $issuer, string $sessionId, int $loggedInAt): bool + { + return $this->isRevoked(self::KEY_PREFIX_SESSION, $issuer, $sessionId, $loggedInAt); + } + + public function isSubjectRevoked(string $issuer, string $subject, int $loggedInAt): bool + { + return $this->isRevoked(self::KEY_PREFIX_SUBJECT, $issuer, $subject, $loggedInAt); + } + + /** + * @throws OidcClientException If the revocation could not be recorded. + */ + protected function recordRevocation(string $keyPrefix, string $issuer, string $value): void + { + $key = $this->buildKey($keyPrefix, $issuer, $value); + + try { + if (!$this->cache->set($key, time(), $this->revocationDuration)) { + throw new OidcClientException('Cache set operation was not successful.'); + } + } catch (Throwable $throwable) { + $error = 'Could not record login revocation. ' . $throwable->getMessage(); + $this->logger?->error($error); + throw new OidcClientException($error, (int) $throwable->getCode(), $throwable); + } + } + + /** + * Check for a revocation entry recorded at, or after, the login time. + * + * A cache read error is logged but reported as "not revoked", so a + * (temporarily) broken cache does not terminate every login. + */ + protected function isRevoked(string $keyPrefix, string $issuer, string $value, int $loggedInAt): bool + { + try { + $revokedAt = $this->cache->get($this->buildKey($keyPrefix, $issuer, $value)); + } catch (Throwable $throwable) { + $this->logger?->error('Could not check for login revocation. ' . $throwable->getMessage()); + return false; + } + + if (!is_numeric($revokedAt)) { + return false; + } + + return (int) $revokedAt >= $loggedInAt; + } + + /** + * Derive the cache key for a revocation entry. The issuer and claim + * value are hashed, so the key stays within the 64 character length that + * PSR-16 implementations are guaranteed to support, and contains no + * reserved characters. + */ + protected function buildKey(string $keyPrefix, string $issuer, string $value): string + { + return $keyPrefix . substr(hash('sha256', $issuer . "\n" . $value), 0, 56); + } +} diff --git a/src/Logout/Interfaces/LoginRevocationRegistryInterface.php b/src/Logout/Interfaces/LoginRevocationRegistryInterface.php new file mode 100644 index 0000000..5f4ba1b --- /dev/null +++ b/src/Logout/Interfaces/LoginRevocationRegistryInterface.php @@ -0,0 +1,72 @@ +requestDataHandler->validateLogoutCallbackResponse($request, $this->useState); } + /** + * Handle an OIDC Back-Channel Logout request from the OP: validate the + * logout token from the request (signature against the OP JWKS, issuer, + * audience, claim set, freshness, 'jti' replay), record the login + * revocation it requests, and deliver the appropriate HTTP response + * (200 when the logout was performed, 400 with a JSON error body when + * not). + * + * Note that a back-channel logout request arrives outside the context + * of the End-User's session, so the affected login can not be removed + * from its session store here. Instead, the revocation is recorded in + * the login revocation registry (backed by the shared client cache by + * default), and the affected persisted login is observed as terminated + * on subsequent reads (see getLoginData()) - the application should + * treat that as the session being logged out. Register the URI of the + * endpoint calling this method as 'backchannel_logout_uri' client + * metadata on the OP. + * + * @param ?ServerRequestInterface $request Back-channel logout request. + * If not provided, it is read from PHP globals. + * @param ?ResponseInterface $response Optional HTTP response which will + * be populated with the proper status / headers / body and returned. If + * not provided, the response is emitted directly and the script is + * terminated. + */ + public function handleBackchannelLogoutRequest( + ?ServerRequestInterface $request = null, + ?ResponseInterface $response = null, + ): ?ResponseInterface { + try { + $logoutToken = $this->requestDataHandler->parseBackchannelLogoutRequest($request); + + if (!is_string($opJwksUri = $this->metadata->get(ClaimsEnum::JwksUri->value))) { + throw new OidcClientException('JWKS URI not found in OP metadata.'); + } + + $logoutTokenJws = $this->requestDataHandler->validateLogoutToken( + logoutToken: $logoutToken, + jwksUri: $opJwksUri, + expectedIssuer: $this->getOptionalMetadataString(ClaimsEnum::Issuer->value), + expectedClientId: $this->clientId, + ); + + $this->requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); + } catch (Throwable $throwable) { + $this->logger?->error('Back-channel logout request error. ' . $throwable->getMessage()); + + return HttpHelper::dispatchBackchannelLogoutResponse( + $response, + $throwable->getMessage(), + $this->logger, + ); + } + + $this->logger?->debug('Back-channel logout performed.'); + + return HttpHelper::dispatchBackchannelLogoutResponse($response, null, $this->logger); + } + /** * 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 diff --git a/src/Protocol/RequestDataHandler.php b/src/Protocol/RequestDataHandler.php index 278fd1e..2c91ea2 100644 --- a/src/Protocol/RequestDataHandler.php +++ b/src/Protocol/RequestDataHandler.php @@ -13,6 +13,8 @@ use Cicnavi\Oidc\DataStore\Interfaces\SessionStoreInterface; use Cicnavi\Oidc\Exceptions\OidcClientException; use Cicnavi\Oidc\Http\RequestFactory; +use Cicnavi\Oidc\Logout\CacheLoginRevocationRegistry; +use Cicnavi\Oidc\Logout\Interfaces\LoginRevocationRegistryInterface; use GuzzleHttp\Client; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\ResponseInterface; @@ -24,9 +26,11 @@ use SimpleSAML\OpenID\Codebooks\ClientAuthenticationMethodsEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; +use SimpleSAML\OpenID\Codebooks\JwtTypesEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Codebooks\PkceCodeChallengeMethodEnum; use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Core\LogoutToken; use SimpleSAML\OpenID\Exceptions\InvalidValueException; use SimpleSAML\OpenID\Exceptions\JwsException; use SimpleSAML\OpenID\Jwks; @@ -48,10 +52,39 @@ class RequestDataHandler */ public const KEY_LOGIN_DATA = 'oidc_login_data'; + /** + * Login data array key under which the Unix timestamp of the login is + * stored. Used to correlate logins with Back-Channel Logout revocations + * (revocations only affect logins established before them). + */ + public const KEY_LOGGED_IN_AT = 'logged_in_at'; + + /** + * Back-channel logout request body parameter carrying the logout token, + * per OIDC Back-Channel Logout 1.0, section 2.5. + */ + public const PARAM_LOGOUT_TOKEN = 'logout_token'; + + /** + * Default maximum accepted age of a logout token ('iat' claim + * freshness). OPs are encouraged to use short logout token lifetimes + * (preferably at most two minutes), so a stale but not-yet-expired token + * is suspicious. + */ + public const DEFAULT_LOGOUT_TOKEN_MAX_AGE = 'PT5M'; + + /** + * Cache key prefix for consumed logout token 'jti' values (replay + * detection). + */ + protected const CACHE_KEY_PREFIX_LOGOUT_TOKEN_JTI = 'bcl_jti_'; + protected StateNonceDataHandlerInterface $stateNonceDataHandler; protected PkceDataHandlerInterface $pkceDataHandler; + protected LoginRevocationRegistryInterface $loginRevocationRegistry; + public function __construct( protected readonly SessionStoreInterface $sessionStore, protected readonly Core $core, @@ -64,9 +97,12 @@ public function __construct( ?PkceDataHandlerInterface $pkceDataHandler = null, protected readonly ?LoggerInterface $logger = null, protected readonly \DateInterval $maxCacheDuration = new \DateInterval('PT6H'), + ?LoginRevocationRegistryInterface $loginRevocationRegistry = null, ) { $this->stateNonceDataHandler = $stateNonceDataHandler ?? new StateNonce($this->sessionStore); $this->pkceDataHandler = $pkceDataHandler ?? new Pkce($this->sessionStore); + $this->loginRevocationRegistry = $loginRevocationRegistry ?? + new CacheLoginRevocationRegistry($this->cache, logger: $this->logger); } /** @@ -982,12 +1018,18 @@ public function storeLoginData( ClaimsEnum::Sid->value => is_string($sid = $claims[ClaimsEnum::Sid->value] ?? null) ? $sid : null, ClaimsEnum::EndSessionEndpoint->value => $opEndSessionEndpoint, ParamsEnum::ClientId->value => $clientId, + self::KEY_LOGGED_IN_AT => time(), ]); } /** * Get the login data persisted after the last successful login, or null - * when not available (no login was performed, or the session expired). + * when not available (no login was performed, the session expired, or + * the login was revoked via OIDC Back-Channel Logout). + * + * When a Back-Channel Logout revocation matching the persisted login is + * found, the login data is cleared (local logout) and null is returned, + * so the application observes the login as terminated. * * @return mixed[]|null */ @@ -995,7 +1037,55 @@ public function getLoginData(): ?array { $loginData = $this->sessionStore->get(self::KEY_LOGIN_DATA); - return is_array($loginData) ? $loginData : null; + if (!is_array($loginData)) { + return null; + } + + if ($this->isLoginDataRevoked($loginData)) { + $this->logger?->info( + 'Persisted login data corresponds to a login revoked via OIDC Back-Channel Logout - ' . + 'clearing it (local logout).', + ); + $this->clearLoginData(); + return null; + } + + return $loginData; + } + + /** + * Check if the given login data corresponds to a login revoked via OIDC + * Back-Channel Logout: by OP issuer and 'sid' (that particular session), + * or by OP issuer and 'sub' (all of the subject's sessions). Logins + * without an 'iss' value can not be correlated with revocations, and + * logins established after a revocation are not affected by it. + * + * @param mixed[] $loginData + */ + protected function isLoginDataRevoked(array $loginData): bool + { + $issuer = $loginData[ClaimsEnum::Iss->value] ?? null; + + if (!is_string($issuer) || $issuer === '') { + return false; + } + + $loggedInAt = $loginData[self::KEY_LOGGED_IN_AT] ?? null; + // Logins persisted without a timestamp are affected by any revocation. + $loggedInAt = is_numeric($loggedInAt) ? (int) $loggedInAt : 0; + + $sid = $loginData[ClaimsEnum::Sid->value] ?? null; + if ( + is_string($sid) && $sid !== '' && + $this->loginRevocationRegistry->isSessionRevoked($issuer, $sid, $loggedInAt) + ) { + return true; + } + + $sub = $loginData[ClaimsEnum::Sub->value] ?? null; + + return is_string($sub) && $sub !== '' && + $this->loginRevocationRegistry->isSubjectRevoked($issuer, $sub, $loggedInAt); } /** @@ -1138,4 +1228,239 @@ public function validateLogoutCallbackResponse( $this->stateNonceDataHandler->verify(StateNonce::LOGOUT_STATE_KEY, $state); } + + /** + * Extract the logout token from an OIDC Back-Channel Logout request + * (HTTP POST with 'application/x-www-form-urlencoded' body carrying a + * 'logout_token' parameter, per specification section 2.5). + * + * @return non-empty-string Raw logout token. Note that it is NOT + * validated yet - use validateLogoutToken() for that. + * @throws OidcClientException If the request is not a valid back-channel + * logout request (the endpoint should respond with HTTP 400). + */ + public function parseBackchannelLogoutRequest(?ServerRequestInterface $request = null): string + { + $method = $request?->getMethod() ?? ($_SERVER['REQUEST_METHOD'] ?? null); + + if (is_string($method) && strtoupper($method) !== HttpMethodsEnum::POST->value) { + throw new OidcClientException('Back-channel logout request must use the HTTP POST method.'); + } + + $parsedBody = $request?->getParsedBody() ?? $_POST; + $logoutToken = is_array($parsedBody) ? ($parsedBody[self::PARAM_LOGOUT_TOKEN] ?? null) : null; + + if (!is_string($logoutToken) || $logoutToken === '') { + throw new OidcClientException( + 'Back-channel logout request does not contain a "logout_token" body parameter.', + ); + } + + return $logoutToken; + } + + /** + * Validate a logout token per OIDC Back-Channel Logout 1.0, section 2.6. + * + * Building the LogoutToken instance validates the claim set (required + * 'iss', 'aud', 'iat', 'exp', 'jti', 'events' with the backchannel-logout + * event member, 'sub' and / or 'sid', forbidden 'nonce', timestamps with + * leeway). This method additionally verifies the signature against the + * OP JWKS (with a one-time JWKS cache refresh retry, like for ID + * tokens), the expected issuer and audience, logout token freshness, and + * 'jti' replay. + * + * @param string $logoutToken Raw logout token. + * @param string $jwksUri OP JWKS URI to verify the signature against. + * @param ?string $expectedIssuer When provided, the 'iss' claim must + * match it. + * @param ?string $expectedClientId When provided, the 'aud' claim must + * contain it. + * @param ?\DateInterval $logoutTokenMaxAge Maximum accepted logout token + * age ('iat' claim freshness), null to disable the check. Default is 5 + * minutes. + * @param bool $checkJtiReplay Whether to reject logout tokens whose + * 'jti' was already consumed (replay detection, uses the cache). + * @param bool $refreshCache Used internally for the JWKS refresh retry. + * @throws OidcClientException If the logout token is not valid (the + * back-channel logout endpoint should respond with HTTP 400). + */ + public function validateLogoutToken( + string $logoutToken, + string $jwksUri, + ?string $expectedIssuer = null, + ?string $expectedClientId = null, + ?\DateInterval $logoutTokenMaxAge = new \DateInterval(self::DEFAULT_LOGOUT_TOKEN_MAX_AGE), + bool $checkJtiReplay = true, + bool $refreshCache = false, + ): LogoutToken { + $jwks = $this->getJwksUriContent($jwksUri, $refreshCache); + + try { + $logoutTokenJws = $this->core->logoutTokenFactory()->fromToken($logoutToken); + } catch (Throwable $throwable) { + $error = 'Error building Logout Token: ' . $throwable->getMessage(); + $this->logger?->error($error, ['logoutToken' => $logoutToken]); + throw new OidcClientException($error, (int) $throwable->getCode(), $throwable); + } + + try { + $logoutTokenJws->verifyWithKeySet($jwks); + } catch (Throwable $throwable) { + // If we have already refreshed our cache (we have fresh JWKS), throw... + if ($refreshCache) { + $error = 'Logout token is not valid. ' . $throwable->getMessage(); + $this->logger?->error($error); + throw new OidcClientException($error, (int) $throwable->getCode(), $throwable); + } + + $this->logger?->warning( + 'Logout token signature verification failed, but trying once more with JWKS refresh.', + ); + // Try once more with refreshing cache (fetch fresh JWKS). + return $this->validateLogoutToken( + logoutToken: $logoutToken, + jwksUri: $jwksUri, + expectedIssuer: $expectedIssuer, + expectedClientId: $expectedClientId, + logoutTokenMaxAge: $logoutTokenMaxAge, + checkJtiReplay: $checkJtiReplay, + refreshCache: true, + ); + } + + // Validate Issuer (iss) + if ($expectedIssuer !== null) { + $iss = $logoutTokenJws->getIssuer(); + if ($iss !== $expectedIssuer) { + $error = sprintf( + 'Logout token issuer claim "%s" does not match expected issuer "%s".', + $iss, + $expectedIssuer, + ); + $this->logger?->error($error); + throw new OidcClientException($error); + } + } + + // Validate Audience (aud) + if ($expectedClientId !== null && !in_array($expectedClientId, $logoutTokenJws->getAudience(), true)) { + $error = sprintf( + 'Logout token audience claim does not contain expected client ID "%s".', + $expectedClientId, + ); + $this->logger?->error($error); + throw new OidcClientException($error); + } + + // Explicit typing ('typ' header of 'logout+jwt') is only RECOMMENDED + // by the specification, and requiring it would break existing OP + // deployments - so an unexpected value is only logged. + $typ = $logoutTokenJws->getType(); + if ($typ !== null && $typ !== JwtTypesEnum::LogoutJwt->value) { + $this->logger?->warning(sprintf( + 'Logout token has unexpected "typ" header value "%s" (expected "%s").', + $typ, + JwtTypesEnum::LogoutJwt->value, + )); + } + + // Logout token freshness (iat). The specification encourages OPs to + // use short logout token lifetimes, and building the LogoutToken + // instance only rejects future 'iat' values - staleness is checked + // here. + if ($logoutTokenMaxAge instanceof \DateInterval) { + $iat = $logoutTokenJws->getIssuedAt(); + if (time() - $iat > $this->dateIntervalToSeconds($logoutTokenMaxAge)) { + $error = sprintf('Logout token is too old (issued at %d).', $iat); + $this->logger?->error($error); + throw new OidcClientException($error); + } + } + + if ($checkJtiReplay) { + $this->validateLogoutTokenJtiNotReplayed($logoutTokenJws); + } + + return $logoutTokenJws; + } + + /** + * Reject logout tokens whose 'jti' value was already consumed (replay + * detection, per specification section 2.6, step 8). Consumed 'jti' + * values are recorded in the cache until the logout token itself can no + * longer validate (expiration plus slack). Cache errors disable the + * check (it is optional per specification) instead of rejecting logouts. + * + * @throws OidcClientException If the logout token 'jti' was already + * consumed. + */ + protected function validateLogoutTokenJtiNotReplayed(LogoutToken $logoutTokenJws): void + { + $cacheKey = self::CACHE_KEY_PREFIX_LOGOUT_TOKEN_JTI . + substr(hash('sha256', $logoutTokenJws->getIssuer() . "\n" . $logoutTokenJws->getJwtId()), 0, 56); + + try { + $consumedAt = $this->cache->get($cacheKey); + } catch (Throwable $throwable) { + $this->logger?->error( + 'Could not check for logout token replay, skipping the check. ' . $throwable->getMessage(), + ); + return; + } + + if ($consumedAt !== null) { + $error = 'Logout token replay detected (a logout token with the same "jti" was already consumed).'; + $this->logger?->error($error); + throw new OidcClientException($error); + } + + try { + $this->cache->set( + $cacheKey, + time(), + max(60, $logoutTokenJws->getExpirationTime() + 300 - time()), + ); + } catch (Throwable $throwable) { + $this->logger?->warning( + 'Could not record consumed logout token "jti". ' . $throwable->getMessage(), + ); + } + } + + /** + * Record the login revocation requested by a (validated) logout token: + * by OP issuer and 'sid' when present (that particular session), else by + * OP issuer and 'sub' (all of the subject's sessions), per specification + * section 2.7. Affected persisted logins are then observed as terminated + * (see getLoginData()). + * + * @throws OidcClientException If the revocation could not be recorded + * (the back-channel logout endpoint should respond with HTTP 400, since + * the logout was not performed). + */ + public function registerLogoutTokenRevocation(LogoutToken $logoutTokenJws): void + { + $issuer = $logoutTokenJws->getIssuer(); + + if (is_string($sid = $logoutTokenJws->getSessionId())) { + $this->loginRevocationRegistry->revokeSession($issuer, $sid); + return; + } + + if (is_string($sub = $logoutTokenJws->getSubject())) { + $this->loginRevocationRegistry->revokeSubject($issuer, $sub); + return; + } + + // Unreachable for validated logout tokens ('sub' and / or 'sid' is + // required), but do not silently acknowledge a logout which can not + // be correlated with any login. + throw new OidcClientException('Logout token does not identify a session or a subject.'); + } + + protected function dateIntervalToSeconds(\DateInterval $dateInterval): int + { + return (new \DateTimeImmutable('@0'))->add($dateInterval)->getTimestamp(); + } } diff --git a/tests/Oidc/DynamicallyRegisteredClientTest.php b/tests/Oidc/DynamicallyRegisteredClientTest.php index 6082858..27f54cd 100644 --- a/tests/Oidc/DynamicallyRegisteredClientTest.php +++ b/tests/Oidc/DynamicallyRegisteredClientTest.php @@ -91,6 +91,8 @@ protected function sut( array $postLogoutRedirectUris = [], ?\Cicnavi\Oidc\Protocol\RequestDataHandler $requestDataHandler = null, bool $injectRequestDataHandler = true, + ?string $backchannelLogoutUri = null, + ?bool $backchannelLogoutSessionRequired = null, ): DynamicallyRegisteredClient { $registrationStore ??= $this->registrationStoreMock; $cache ??= $this->cacheMock; @@ -127,6 +129,8 @@ protected function sut( registrationHandler: $registrationHandler, preRegisteredClient: $preRegisteredClient, postLogoutRedirectUris: $postLogoutRedirectUris, + backchannelLogoutUri: $backchannelLogoutUri, + backchannelLogoutSessionRequired: $backchannelLogoutSessionRequired, ); } @@ -990,4 +994,157 @@ public function testThrowsOnCacheError(): void $this->sut(cache: $cacheMock); } + + public function testCanBuildClientRegistrationMetadataWithBackchannelLogoutUri(): void + { + $clientMetadata = $this->sut( + backchannelLogoutUri: 'https://rp.example.org/backchannel-logout', + backchannelLogoutSessionRequired: true, + )->buildClientRegistrationMetadata(); + + $this->assertSame('https://rp.example.org/backchannel-logout', $clientMetadata['backchannel_logout_uri']); + $this->assertTrue($clientMetadata['backchannel_logout_session_required']); + } + + public function testBackchannelLogoutSessionRequiredIsOmittedWithoutBackchannelLogoutUri(): void + { + $clientMetadata = $this->sut(backchannelLogoutSessionRequired: true)->buildClientRegistrationMetadata(); + + $this->assertArrayNotHasKey('backchannel_logout_uri', $clientMetadata); + $this->assertArrayNotHasKey('backchannel_logout_session_required', $clientMetadata); + } + + public function testThrowsOnEmptyBackchannelLogoutUri(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Backchannel logout URI must be a non-empty string.'); + + $this->sut(backchannelLogoutUri: ''); + } + + public function testThrowsOnConflictingBackchannelLogoutUriOverride(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('backchannel_logout_uri'); + + $this->sut( + backchannelLogoutUri: 'https://rp.example.org/backchannel-logout', + additionalClientMetadata: ['backchannel_logout_uri' => 'https://rp.example.org/other'], + ); + } + + public function testAllowsMatchingBackchannelLogoutUriOverride(): void + { + $clientMetadata = $this->sut( + backchannelLogoutUri: 'https://rp.example.org/backchannel-logout', + additionalClientMetadata: ['backchannel_logout_uri' => 'https://rp.example.org/backchannel-logout'], + )->buildClientRegistrationMetadata(); + + $this->assertSame('https://rp.example.org/backchannel-logout', $clientMetadata['backchannel_logout_uri']); + } + + public function testHandleBackchannelLogoutRequestSuccess(): void + { + $request = $this->createStub(\Psr\Http\Message\ServerRequestInterface::class); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('parseBackchannelLogoutRequest') + ->with($request) + ->willReturn('logout-token'); + + $this->metadataMock->method('get')->willReturnMap([ + ['jwks_uri', 'https://op.example.org/jwks'], + ['issuer', 'https://op.example.org'], + ]); + + // Persisted client registration provides the expected audience. + $this->registrationStoreMock->method('get')->willReturn([ + 'client_id' => 'registered-client-id', + 'client_secret' => 'client-secret', + ]); + + $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutToken') + ->with( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + 'registered-client-id', + ) + ->willReturn($logoutTokenJws); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('registerLogoutTokenRevocation') + ->with($logoutTokenJws); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(200)->willReturn($response); + $response->expects($this->once()) + ->method('withHeader') + ->with('Cache-Control', 'no-store') + ->willReturn($response); + + $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest($request, $response)); + } + + public function testHandleBackchannelLogoutRequestRespondsWith400WithoutRegistration(): void + { + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + + $this->metadataMock->method('get')->willReturnMap([ + ['jwks_uri', 'https://op.example.org/jwks'], + ['issuer', 'https://op.example.org'], + ]); + + // No persisted client registration. + $this->registrationStoreMock->method('get')->willReturn(null); + + $this->requestDataHandlerMock->expects($this->never())->method('validateLogoutToken'); + + $body = $this->createMock(\Psr\Http\Message\StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->stringContains('No persisted client registration found')); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(400)->willReturn($response); + $response->method('withHeader')->willReturn($response); + $response->method('getBody')->willReturn($body); + + $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); + } + + public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogoutToken(): void + { + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + + $this->metadataMock->method('get')->willReturnMap([ + ['jwks_uri', 'https://op.example.org/jwks'], + ['issuer', 'https://op.example.org'], + ]); + + $this->registrationStoreMock->method('get')->willReturn([ + 'client_id' => 'registered-client-id', + 'client_secret' => 'client-secret', + ]); + + $this->requestDataHandlerMock->method('validateLogoutToken') + ->willThrowException(new OidcClientException('Logout token is not valid.')); + + $this->requestDataHandlerMock->expects($this->never())->method('registerLogoutTokenRevocation'); + + $body = $this->createMock(\Psr\Http\Message\StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->stringContains('Logout token is not valid.')); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(400)->willReturn($response); + $response->method('withHeader')->willReturn($response); + $response->method('getBody')->willReturn($body); + + $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); + } } diff --git a/tests/Oidc/FederatedClientTest.php b/tests/Oidc/FederatedClientTest.php index a83250f..c460576 100644 --- a/tests/Oidc/FederatedClientTest.php +++ b/tests/Oidc/FederatedClientTest.php @@ -1190,4 +1190,149 @@ public function testAutoRegisterAndAuthenticateUsesParWithPlainParameters(): voi $result = $this->sut()->autoRegisterAndAuthenticate($opEntityId, response: $responseMock); $this->assertSame($responseMock, $result); } + + /** + * Set up the logout token factory mock producing a LogoutToken stub + * with the given issuer (used to determine the OP to resolve trust for). + */ + private function mockLogoutTokenWithIssuer(string $issuer): void + { + $logoutTokenFactoryMock = $this->createMock(\SimpleSAML\OpenID\Core\Factories\LogoutTokenFactory::class); + $this->coreMock->method('logoutTokenFactory')->willReturn($logoutTokenFactoryMock); + $logoutTokenStub = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + $logoutTokenStub->method('getIssuer')->willReturn($issuer); + $logoutTokenFactoryMock->method('fromToken')->willReturn($logoutTokenStub); + } + + public function testHandleBackchannelLogoutRequestSuccess(): void + { + $opEntityId = 'https://op.example.org'; + + $this->requestDataHandlerMock->expects($this->once()) + ->method('parseBackchannelLogoutRequest') + ->willReturn('logout-token'); + + $this->mockLogoutTokenWithIssuer($opEntityId); + + $trustAnchorBagMock = $this->createMock(TrustAnchorConfigBag::class); + $trustAnchorBagMock->method('getAllEntityIds')->willReturn(['https://ta.example.org']); + $this->entityConfigMock->method('getTrustAnchorBag')->willReturn($trustAnchorBagMock); + $this->entityConfigMock->method('getEntityId')->willReturn('https://rp.example.org'); + + $opTrustChainBagMock = $this->createMock(TrustChainBag::class); + $trustChainResolverMock = $this->createMock(TrustChainResolver::class); + $this->federationMock->method('trustChainResolver')->willReturn($trustChainResolverMock); + $trustChainResolverMock->expects($this->once()) + ->method('for') + ->with($opEntityId, ['https://ta.example.org']) + ->willReturn($opTrustChainBagMock); + + $opTrustChainMock = $this->createMock(TrustChain::class); + $opTrustChainBagMock->method('getShortest')->willReturn($opTrustChainMock); + $opTrustChainMock->method('getResolvedMetadata') + ->willReturn(['jwks_uri' => 'https://op.example.org/jwks']); + + $validatedLogoutTokenStub = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutToken') + ->with( + 'logout-token', + 'https://op.example.org/jwks', + $opEntityId, + 'https://rp.example.org', + ) + ->willReturn($validatedLogoutTokenStub); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('registerLogoutTokenRevocation') + ->with($validatedLogoutTokenStub); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->expects($this->once())->method('withStatus')->with(200)->willReturn($responseMock); + $responseMock->expects($this->once()) + ->method('withHeader') + ->with('Cache-Control', 'no-store') + ->willReturn($responseMock); + + $this->assertSame( + $responseMock, + $this->sut()->handleBackchannelLogoutRequest(null, $responseMock), + ); + } + + public function testHandleBackchannelLogoutRequestRespondsWith400OnTrustChainError(): void + { + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + + $this->mockLogoutTokenWithIssuer('https://op.example.org'); + + $trustAnchorBagMock = $this->createMock(TrustAnchorConfigBag::class); + $trustAnchorBagMock->method('getAllEntityIds')->willReturn(['https://ta.example.org']); + $this->entityConfigMock->method('getTrustAnchorBag')->willReturn($trustAnchorBagMock); + + $trustChainResolverMock = $this->createMock(TrustChainResolver::class); + $this->federationMock->method('trustChainResolver')->willReturn($trustChainResolverMock); + $trustChainResolverMock->method('for') + ->willThrowException(new TrustChainException('Resolution failed')); + + $this->requestDataHandlerMock->expects($this->never())->method('validateLogoutToken'); + + $body = $this->createMock(StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->stringContains('Resolution failed')); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->expects($this->once())->method('withStatus')->with(400)->willReturn($responseMock); + $responseMock->method('withHeader')->willReturn($responseMock); + $responseMock->method('getBody')->willReturn($body); + + $this->assertSame( + $responseMock, + $this->sut()->handleBackchannelLogoutRequest(null, $responseMock), + ); + } + + public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogoutToken(): void + { + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + + $this->mockLogoutTokenWithIssuer('https://op.example.org'); + + $trustAnchorBagMock = $this->createMock(TrustAnchorConfigBag::class); + $trustAnchorBagMock->method('getAllEntityIds')->willReturn(['https://ta.example.org']); + $this->entityConfigMock->method('getTrustAnchorBag')->willReturn($trustAnchorBagMock); + $this->entityConfigMock->method('getEntityId')->willReturn('https://rp.example.org'); + + $opTrustChainBagMock = $this->createMock(TrustChainBag::class); + $trustChainResolverMock = $this->createMock(TrustChainResolver::class); + $this->federationMock->method('trustChainResolver')->willReturn($trustChainResolverMock); + $trustChainResolverMock->method('for')->willReturn($opTrustChainBagMock); + + $opTrustChainMock = $this->createMock(TrustChain::class); + $opTrustChainBagMock->method('getShortest')->willReturn($opTrustChainMock); + $opTrustChainMock->method('getResolvedMetadata') + ->willReturn(['jwks_uri' => 'https://op.example.org/jwks']); + + $this->requestDataHandlerMock->method('validateLogoutToken') + ->willThrowException(new OidcClientException('Logout token is not valid.')); + + $this->requestDataHandlerMock->expects($this->never())->method('registerLogoutTokenRevocation'); + + $body = $this->createMock(StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->stringContains('Logout token is not valid.')); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->expects($this->once())->method('withStatus')->with(400)->willReturn($responseMock); + $responseMock->method('withHeader')->willReturn($responseMock); + $responseMock->method('getBody')->willReturn($body); + + $this->assertSame( + $responseMock, + $this->sut()->handleBackchannelLogoutRequest(null, $responseMock), + ); + } } diff --git a/tests/Oidc/Helpers/HttpHelperTest.php b/tests/Oidc/Helpers/HttpHelperTest.php index d306499..d6605c5 100644 --- a/tests/Oidc/Helpers/HttpHelperTest.php +++ b/tests/Oidc/Helpers/HttpHelperTest.php @@ -185,4 +185,63 @@ public function testDispatchFrontChannelRequestFormPostPopulatesResponseBody(): $this->assertSame($response, $result); } + + public function testDispatchBackchannelLogoutResponsePopulatesSuccessResponse(): void + { + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once()) + ->method('withStatus') + ->with(200) + ->willReturn($response); + $response->expects($this->once()) + ->method('withHeader') + ->with('Cache-Control', 'no-store') + ->willReturn($response); + $response->expects($this->never())->method('getBody'); + + $result = HttpHelper::dispatchBackchannelLogoutResponse($response); + + $this->assertSame($response, $result); + } + + public function testDispatchBackchannelLogoutResponsePopulatesErrorResponse(): void + { + $body = $this->createMock(\Psr\Http\Message\StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->callback(fn(string $json): bool => + str_contains($json, '"error": "invalid_request"') === false && // pretty print not used + str_contains($json, '"invalid_request"') && + str_contains($json, 'Logout token is not valid.'))); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once()) + ->method('withStatus') + ->with(400) + ->willReturn($response); + $response->method('getBody')->willReturn($body); + + $headers = []; + $response->expects($this->exactly(2)) + ->method('withHeader') + ->willReturnCallback(function (string $name, string $value) use (&$headers, $response) { + $headers[$name] = $value; + return $response; + }); + + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $logger->expects($this->once())->method('debug'); + + $result = HttpHelper::dispatchBackchannelLogoutResponse( + $response, + 'Logout token is not valid.', + $logger, + ); + + $this->assertSame($response, $result); + $this->assertSame( + ['Cache-Control' => 'no-store', 'Content-Type' => 'application/json'], + $headers, + ); + } } diff --git a/tests/Oidc/Logout/CacheLoginRevocationRegistryTest.php b/tests/Oidc/Logout/CacheLoginRevocationRegistryTest.php new file mode 100644 index 0000000..881455a --- /dev/null +++ b/tests/Oidc/Logout/CacheLoginRevocationRegistryTest.php @@ -0,0 +1,152 @@ + + */ + private array $cacheStorage = []; + + private MockObject $cacheMock; + + private MockObject $loggerMock; + + protected function setUp(): void + { + $this->cacheStorage = []; + + $this->cacheMock = $this->createMock(CacheInterface::class); + $this->cacheMock->method('set') + ->willReturnCallback(function (string $key, mixed $value): bool { + $this->cacheStorage[$key] = $value; + return true; + }); + $this->cacheMock->method('get') + ->willReturnCallback(fn(string $key): mixed => $this->cacheStorage[$key] ?? null); + + $this->loggerMock = $this->createMock(LoggerInterface::class); + } + + protected function sut( + ?CacheInterface $cache = null, + ?DateInterval $revocationDuration = null, + ?LoggerInterface $logger = null, + ): CacheLoginRevocationRegistry { + /** @var CacheInterface $cache */ + $cache ??= $this->cacheMock; + $revocationDuration ??= new DateInterval('P1D'); + $logger ??= $this->loggerMock; + + return new CacheLoginRevocationRegistry($cache, $revocationDuration, $logger); + } + + public function testCanRevokeSession(): void + { + $sut = $this->sut(); + + $this->assertFalse($sut->isSessionRevoked(self::ISSUER, 'sid-123', time() - 60)); + + $sut->revokeSession(self::ISSUER, 'sid-123'); + + $this->assertTrue($sut->isSessionRevoked(self::ISSUER, 'sid-123', time() - 60)); + } + + public function testCanRevokeSubject(): void + { + $sut = $this->sut(); + + $this->assertFalse($sut->isSubjectRevoked(self::ISSUER, 'sub-123', time() - 60)); + + $sut->revokeSubject(self::ISSUER, 'sub-123'); + + $this->assertTrue($sut->isSubjectRevoked(self::ISSUER, 'sub-123', time() - 60)); + } + + public function testLoginEstablishedAfterRevocationIsNotRevoked(): void + { + $sut = $this->sut(); + + $sut->revokeSession(self::ISSUER, 'sid-123'); + $sut->revokeSubject(self::ISSUER, 'sub-123'); + + $this->assertFalse($sut->isSessionRevoked(self::ISSUER, 'sid-123', time() + 60)); + $this->assertFalse($sut->isSubjectRevoked(self::ISSUER, 'sub-123', time() + 60)); + } + + public function testSessionAndSubjectRevocationsAreSeparate(): void + { + $sut = $this->sut(); + + $sut->revokeSession(self::ISSUER, 'value-123'); + + $this->assertFalse($sut->isSubjectRevoked(self::ISSUER, 'value-123', time() - 60)); + } + + public function testRevocationsAreScopedToIssuer(): void + { + $sut = $this->sut(); + + $sut->revokeSession(self::ISSUER, 'sid-123'); + + $this->assertFalse($sut->isSessionRevoked('https://other-op.example.com', 'sid-123', time() - 60)); + } + + public function testRevokeThrowsWhenCacheSetFails(): void + { + $cacheMock = $this->createMock(CacheInterface::class); + $cacheMock->method('set')->willReturn(false); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Could not record login revocation.'); + + $this->sut(cache: $cacheMock)->revokeSession(self::ISSUER, 'sid-123'); + } + + public function testRevokeThrowsWhenCacheSetThrows(): void + { + $cacheMock = $this->createMock(CacheInterface::class); + $cacheMock->method('set')->willThrowException(new Exception('Cache error.')); + + $this->loggerMock->expects($this->once())->method('error'); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Cache error.'); + + $this->sut(cache: $cacheMock)->revokeSubject(self::ISSUER, 'sub-123'); + } + + public function testIsRevokedReturnsFalseWhenCacheGetThrows(): void + { + $cacheMock = $this->createMock(CacheInterface::class); + $cacheMock->method('get')->willThrowException(new Exception('Cache error.')); + + $this->loggerMock->expects($this->once())->method('error'); + + $this->assertFalse($this->sut(cache: $cacheMock)->isSessionRevoked(self::ISSUER, 'sid-123', time())); + } + + public function testIsRevokedReturnsFalseForNonNumericCacheValue(): void + { + $cacheMock = $this->createMock(CacheInterface::class); + $cacheMock->method('get')->willReturn('not-a-timestamp'); + + $this->assertFalse($this->sut(cache: $cacheMock)->isSessionRevoked(self::ISSUER, 'sid-123', time())); + } +} diff --git a/tests/Oidc/PreRegisteredClientTest.php b/tests/Oidc/PreRegisteredClientTest.php index a8dc4cf..da7a95e 100644 --- a/tests/Oidc/PreRegisteredClientTest.php +++ b/tests/Oidc/PreRegisteredClientTest.php @@ -765,4 +765,93 @@ public function testGetLoginDataDelegates(): void $this->assertSame(['id_token' => 'id-token'], $this->sut()->getLoginData()); } + + public function testHandleBackchannelLogoutRequestSuccess(): void + { + $request = $this->createStub(\Psr\Http\Message\ServerRequestInterface::class); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('parseBackchannelLogoutRequest') + ->with($request) + ->willReturn('logout-token'); + + $this->metadataMock->method('get')->willReturnMap([ + ['jwks_uri', 'https://op.example.org/jwks'], + ['issuer', 'https://op.example.org'], + ]); + + $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutToken') + ->with( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + $this->clientId, + ) + ->willReturn($logoutTokenJws); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('registerLogoutTokenRevocation') + ->with($logoutTokenJws); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(200)->willReturn($response); + $response->expects($this->once()) + ->method('withHeader') + ->with('Cache-Control', 'no-store') + ->willReturn($response); + + $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest($request, $response)); + } + + public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogoutToken(): void + { + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + + $this->metadataMock->method('get')->willReturnMap([ + ['jwks_uri', 'https://op.example.org/jwks'], + ['issuer', 'https://op.example.org'], + ]); + + $this->requestDataHandlerMock->method('validateLogoutToken') + ->willThrowException(new \Cicnavi\Oidc\Exceptions\OidcClientException('Logout token is not valid.')); + + $this->requestDataHandlerMock->expects($this->never())->method('registerLogoutTokenRevocation'); + + $body = $this->createMock(\Psr\Http\Message\StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->stringContains('Logout token is not valid.')); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(400)->willReturn($response); + $response->method('withHeader')->willReturn($response); + $response->method('getBody')->willReturn($body); + + $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); + } + + public function testHandleBackchannelLogoutRequestRespondsWith400OnParseError(): void + { + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest') + ->willThrowException(new \Cicnavi\Oidc\Exceptions\OidcClientException( + 'Back-channel logout request must use the HTTP POST method.', + )); + + $this->requestDataHandlerMock->expects($this->never())->method('validateLogoutToken'); + + $body = $this->createMock(\Psr\Http\Message\StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->stringContains('must use the HTTP POST method')); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(400)->willReturn($response); + $response->method('withHeader')->willReturn($response); + $response->method('getBody')->willReturn($body); + + $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); + } } diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index 5036313..b0f0d00 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -11,6 +11,7 @@ use Cicnavi\Oidc\DataStore\DataHandlers\StateNonce; use Cicnavi\Oidc\DataStore\Interfaces\SessionStoreInterface; use Cicnavi\Oidc\Exceptions\OidcClientException; +use Cicnavi\Oidc\Logout\Interfaces\LoginRevocationRegistryInterface; use Cicnavi\Oidc\Protocol\RequestDataHandler; use DateInterval; use Exception; @@ -33,6 +34,8 @@ use SimpleSAML\OpenID\Exceptions\JwsException; use SimpleSAML\OpenID\Core\IdToken; use SimpleSAML\OpenID\Core\Factories\IdTokenFactory; +use SimpleSAML\OpenID\Core\Factories\LogoutTokenFactory; +use SimpleSAML\OpenID\Core\LogoutToken; use SimpleSAML\OpenID\Jwks; use SimpleSAML\OpenID\Jwks\JwksDecorator; use SimpleSAML\OpenID\Jwks\JwksFetcher; @@ -65,6 +68,8 @@ final class RequestDataHandlerTest extends TestCase private DateInterval $maxCacheDuration; + private MockObject $loginRevocationRegistryMock; + protected function setUp(): void { $this->sessionStoreMock = $this->createMock(SessionStoreInterface::class); @@ -78,6 +83,7 @@ protected function setUp(): void $this->pkceDataHandlerMock = $this->createMock(PkceDataHandlerInterface::class); $this->loggerMock = $this->createMock(LoggerInterface::class); $this->maxCacheDuration = new DateInterval('PT6H'); + $this->loginRevocationRegistryMock = $this->createMock(LoginRevocationRegistryInterface::class); } protected function sut( @@ -92,6 +98,7 @@ protected function sut( ?PkceDataHandlerInterface $pkceDataHandler = null, ?LoggerInterface $logger = null, ?DateInterval $maxCacheDuration = null, + ?LoginRevocationRegistryInterface $loginRevocationRegistry = null, ): RequestDataHandler { $sessionStore ??= $this->sessionStoreMock; $core ??= $this->coreMock; @@ -104,6 +111,8 @@ protected function sut( $pkceDataHandler ??= $this->pkceDataHandlerMock; $logger ??= $this->loggerMock; $maxCacheDuration ??= $this->maxCacheDuration; + /** @var LoginRevocationRegistryInterface $loginRevocationRegistry */ + $loginRevocationRegistry ??= $this->loginRevocationRegistryMock; return new RequestDataHandler( $sessionStore, @@ -117,9 +126,34 @@ protected function sut( $pkceDataHandler, $logger, $maxCacheDuration, + $loginRevocationRegistry, ); } + /** + * Expect login data to be persisted in the session store with the given + * values plus a current 'logged_in_at' timestamp. + * + * @param array $expectedLoginData Expected login data, + * without the 'logged_in_at' entry. + */ + private function expectLoginDataPut(array $expectedLoginData): void + { + $this->sessionStoreMock->expects($this->once()) + ->method('put') + ->with( + RequestDataHandler::KEY_LOGIN_DATA, + $this->callback(function (array $loginData) use ($expectedLoginData): bool { + $loggedInAt = $loginData[RequestDataHandler::KEY_LOGGED_IN_AT] ?? null; + unset($loginData[RequestDataHandler::KEY_LOGGED_IN_AT]); + + return $loginData === $expectedLoginData && + is_int($loggedInAt) && + abs($loggedInAt - time()) < 60; + }), + ); + } + public function testCanCreateInstance(): void { $this->assertInstanceOf(RequestDataHandler::class, $this->sut()); @@ -1384,16 +1418,14 @@ public function testStoreLoginDataStoresIdTokenAndClaims(): void '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', - 'client_id' => 'client-id', - ]); + $this->expectLoginDataPut([ + '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', + 'client_id' => 'client-id', + ]); $this->sut()->storeLoginData('id-token', 'https://op.example.org/end-session', 'client-id'); } @@ -1406,16 +1438,14 @@ public function testStoreLoginDataStoresRawIdTokenOnClaimExtractionError(): void $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, - 'client_id' => null, - ]); + $this->expectLoginDataPut([ + 'id_token' => 'id-token', + 'iss' => null, + 'sub' => null, + 'sid' => null, + 'end_session_endpoint' => null, + 'client_id' => null, + ]); $this->sut()->storeLoginData('id-token'); } @@ -1424,16 +1454,14 @@ 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', - 'client_id' => 'client-id', - ]); + $this->expectLoginDataPut([ + 'id_token' => null, + 'iss' => null, + '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', 'client-id'); } @@ -1539,16 +1567,14 @@ public function testGetUserDataStoresLoginData(): void ->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', - 'client_id' => 'client-id', - ]); + $this->expectLoginDataPut([ + 'id_token' => 'id-token', + 'iss' => 'https://op.example.org', + 'sub' => 'sub1', + 'sid' => 'op-session-1', + 'end_session_endpoint' => 'https://op.example.org/end-session', + 'client_id' => 'client-id', + ]); $this->sut()->getUserData( ClientAuthenticationMethodsEnum::ClientSecretPost, @@ -1648,4 +1674,394 @@ public function testValidateLogoutCallbackResponseSkipsVerificationWithoutState( $this->sut()->validateLogoutCallbackResponse($request, false); } + + public function testParseBackchannelLogoutRequestReturnsLogoutToken(): void + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getMethod')->willReturn('POST'); + $request->method('getParsedBody')->willReturn([ + RequestDataHandler::PARAM_LOGOUT_TOKEN => 'logout-token', + ]); + + $this->assertSame('logout-token', $this->sut()->parseBackchannelLogoutRequest($request)); + } + + public function testParseBackchannelLogoutRequestThrowsOnNonPostMethod(): void + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Back-channel logout request must use the HTTP POST method.'); + + $this->sut()->parseBackchannelLogoutRequest($request); + } + + public function testParseBackchannelLogoutRequestThrowsOnMissingLogoutToken(): void + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getMethod')->willReturn('POST'); + $request->method('getParsedBody')->willReturn([]); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Back-channel logout request does not contain a "logout_token"'); + + $this->sut()->parseBackchannelLogoutRequest($request); + } + + /** + * Set up JWKS and logout token factory mocks, returning the LogoutToken + * mock which the factory produces. + */ + private function mockLogoutTokenSetup(): MockObject + { + $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); + $jwksFetcher->method('fromJwksUri')->willReturn($keySet); + + $logoutTokenFactory = $this->createMock(LogoutTokenFactory::class); + $this->coreMock->method('logoutTokenFactory')->willReturn($logoutTokenFactory); + $logoutTokenJws = $this->createMock(LogoutToken::class); + $logoutTokenFactory->method('fromToken')->willReturn($logoutTokenJws); + + return $logoutTokenJws; + } + + /** + * Configure a LogoutToken mock with a valid claim set. + */ + private function configureValidLogoutToken(MockObject $logoutTokenJws): void + { + $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); + $logoutTokenJws->method('getAudience')->willReturn(['client-id']); + $logoutTokenJws->method('getType')->willReturn('logout+jwt'); + $logoutTokenJws->method('getIssuedAt')->willReturn(time()); + $logoutTokenJws->method('getExpirationTime')->willReturn(time() + 120); + $logoutTokenJws->method('getJwtId')->willReturn('jti-1'); + $logoutTokenJws->method('getSessionId')->willReturn('op-session-1'); + } + + public function testValidateLogoutTokenReturnsVerifiedToken(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $this->configureValidLogoutToken($logoutTokenJws); + $logoutTokenJws->expects($this->once())->method('verifyWithKeySet'); + + $result = $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + 'client-id', + ); + + $this->assertSame($logoutTokenJws, $result); + } + + public function testValidateLogoutTokenThrowsOnBuildError(): void + { + $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); + + $logoutTokenFactory = $this->createMock(LogoutTokenFactory::class); + $this->coreMock->method('logoutTokenFactory')->willReturn($logoutTokenFactory); + $logoutTokenFactory->method('fromToken')->willThrowException(new JwsException('Invalid token')); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Error building Logout Token: Invalid token'); + + $this->sut()->validateLogoutToken('invalid-token', 'https://op.example.org/jwks'); + } + + public function testValidateLogoutTokenRetriesOnVerificationFailure(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $this->configureValidLogoutToken($logoutTokenJws); + + // First verification fails, second (after JWKS refresh) succeeds. + $logoutTokenJws->expects($this->exactly(2))->method('verifyWithKeySet') + ->willReturnOnConsecutiveCalls( + $this->throwException(new Exception('Sig fail')), + null, + ); + + $result = $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + 'client-id', + ); + + $this->assertSame($logoutTokenJws, $result); + } + + public function testValidateLogoutTokenThrowsAfterRetryFailure(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $logoutTokenJws->method('verifyWithKeySet')->willThrowException(new Exception('Sig fail')); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Logout token is not valid. Sig fail'); + + $this->sut()->validateLogoutToken('logout-token', 'https://op.example.org/jwks'); + } + + public function testValidateLogoutTokenThrowsOnIssuerMismatch(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $this->configureValidLogoutToken($logoutTokenJws); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('does not match expected issuer'); + + $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + 'https://other-op.example.org', + ); + } + + public function testValidateLogoutTokenThrowsOnAudienceMismatch(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $this->configureValidLogoutToken($logoutTokenJws); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('does not contain expected client ID'); + + $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + 'other-client-id', + ); + } + + public function testValidateLogoutTokenWarnsOnUnexpectedTypHeader(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); + $logoutTokenJws->method('getAudience')->willReturn(['client-id']); + $logoutTokenJws->method('getType')->willReturn('JWT'); + $logoutTokenJws->method('getIssuedAt')->willReturn(time()); + $logoutTokenJws->method('getExpirationTime')->willReturn(time() + 120); + $logoutTokenJws->method('getJwtId')->willReturn('jti-1'); + + $this->loggerMock->expects($this->once())->method('warning'); + + $result = $this->sut()->validateLogoutToken('logout-token', 'https://op.example.org/jwks'); + + $this->assertSame($logoutTokenJws, $result); + } + + public function testValidateLogoutTokenThrowsOnStaleIssuedAt(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); + $logoutTokenJws->method('getAudience')->willReturn(['client-id']); + $logoutTokenJws->method('getType')->willReturn('logout+jwt'); + // Older than the default maximum age of 5 minutes. + $logoutTokenJws->method('getIssuedAt')->willReturn(time() - 600); + $logoutTokenJws->method('getExpirationTime')->willReturn(time() + 120); + $logoutTokenJws->method('getJwtId')->willReturn('jti-1'); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Logout token is too old'); + + $this->sut()->validateLogoutToken('logout-token', 'https://op.example.org/jwks'); + } + + public function testValidateLogoutTokenAcceptsStaleIssuedAtWhenMaxAgeDisabled(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); + $logoutTokenJws->method('getAudience')->willReturn(['client-id']); + $logoutTokenJws->method('getType')->willReturn('logout+jwt'); + $logoutTokenJws->method('getIssuedAt')->willReturn(time() - 600); + $logoutTokenJws->method('getExpirationTime')->willReturn(time() + 120); + $logoutTokenJws->method('getJwtId')->willReturn('jti-1'); + + $result = $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + logoutTokenMaxAge: null, + ); + + $this->assertSame($logoutTokenJws, $result); + } + + public function testValidateLogoutTokenThrowsOnJtiReplay(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $this->configureValidLogoutToken($logoutTokenJws); + + // A logout token with the same jti was already consumed. + $this->cacheMock->method('get')->willReturn(time()); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Logout token replay detected'); + + $this->sut()->validateLogoutToken('logout-token', 'https://op.example.org/jwks'); + } + + public function testValidateLogoutTokenSkipsJtiReplayCheckWhenDisabled(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $this->configureValidLogoutToken($logoutTokenJws); + + $this->cacheMock->method('get')->willReturn(time()); + + $result = $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + checkJtiReplay: false, + ); + + $this->assertSame($logoutTokenJws, $result); + } + + public function testValidateLogoutTokenSkipsJtiReplayCheckOnCacheError(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $this->configureValidLogoutToken($logoutTokenJws); + + $this->cacheMock->method('get')->willThrowException(new Exception('Cache error.')); + + $result = $this->sut()->validateLogoutToken('logout-token', 'https://op.example.org/jwks'); + + $this->assertSame($logoutTokenJws, $result); + } + + public function testRegisterLogoutTokenRevocationRevokesSession(): void + { + $logoutTokenJws = $this->createMock(LogoutToken::class); + $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); + $logoutTokenJws->method('getSessionId')->willReturn('op-session-1'); + $logoutTokenJws->method('getSubject')->willReturn('user-1'); + + $this->loginRevocationRegistryMock->expects($this->once()) + ->method('revokeSession') + ->with('https://op.example.org', 'op-session-1'); + $this->loginRevocationRegistryMock->expects($this->never())->method('revokeSubject'); + + $this->sut()->registerLogoutTokenRevocation($logoutTokenJws); + } + + public function testRegisterLogoutTokenRevocationRevokesSubjectWithoutSessionId(): void + { + $logoutTokenJws = $this->createMock(LogoutToken::class); + $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); + $logoutTokenJws->method('getSessionId')->willReturn(null); + $logoutTokenJws->method('getSubject')->willReturn('user-1'); + + $this->loginRevocationRegistryMock->expects($this->never())->method('revokeSession'); + $this->loginRevocationRegistryMock->expects($this->once()) + ->method('revokeSubject') + ->with('https://op.example.org', 'user-1'); + + $this->sut()->registerLogoutTokenRevocation($logoutTokenJws); + } + + public function testRegisterLogoutTokenRevocationThrowsWithoutSessionIdAndSubject(): void + { + $logoutTokenJws = $this->createMock(LogoutToken::class); + $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); + $logoutTokenJws->method('getSessionId')->willReturn(null); + $logoutTokenJws->method('getSubject')->willReturn(null); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Logout token does not identify a session or a subject.'); + + $this->sut()->registerLogoutTokenRevocation($logoutTokenJws); + } + + public function testGetLoginDataClearsRevokedSessionLogin(): void + { + $loggedInAt = time() - 60; + $this->sessionStoreMock->method('get') + ->with(RequestDataHandler::KEY_LOGIN_DATA) + ->willReturn([ + 'iss' => 'https://op.example.org', + 'sub' => 'user-1', + 'sid' => 'op-session-1', + RequestDataHandler::KEY_LOGGED_IN_AT => $loggedInAt, + ]); + + $this->loginRevocationRegistryMock->method('isSessionRevoked') + ->with('https://op.example.org', 'op-session-1', $loggedInAt) + ->willReturn(true); + + $this->sessionStoreMock->expects($this->once()) + ->method('delete') + ->with(RequestDataHandler::KEY_LOGIN_DATA); + + $this->assertNull($this->sut()->getLoginData()); + } + + public function testGetLoginDataClearsRevokedSubjectLogin(): void + { + $loggedInAt = time() - 60; + $this->sessionStoreMock->method('get') + ->with(RequestDataHandler::KEY_LOGIN_DATA) + ->willReturn([ + 'iss' => 'https://op.example.org', + 'sub' => 'user-1', + 'sid' => 'op-session-1', + RequestDataHandler::KEY_LOGGED_IN_AT => $loggedInAt, + ]); + + $this->loginRevocationRegistryMock->method('isSessionRevoked')->willReturn(false); + $this->loginRevocationRegistryMock->method('isSubjectRevoked') + ->with('https://op.example.org', 'user-1', $loggedInAt) + ->willReturn(true); + + $this->sessionStoreMock->expects($this->once()) + ->method('delete') + ->with(RequestDataHandler::KEY_LOGIN_DATA); + + $this->assertNull($this->sut()->getLoginData()); + } + + public function testGetLoginDataReturnsDataWhenNotRevoked(): void + { + $loginData = [ + 'iss' => 'https://op.example.org', + 'sub' => 'user-1', + 'sid' => 'op-session-1', + RequestDataHandler::KEY_LOGGED_IN_AT => time() - 60, + ]; + $this->sessionStoreMock->method('get') + ->with(RequestDataHandler::KEY_LOGIN_DATA) + ->willReturn($loginData); + + $this->loginRevocationRegistryMock->method('isSessionRevoked')->willReturn(false); + $this->loginRevocationRegistryMock->method('isSubjectRevoked')->willReturn(false); + + $this->sessionStoreMock->expects($this->never())->method('delete'); + + $this->assertSame($loginData, $this->sut()->getLoginData()); + } + + public function testGetLoginDataSkipsRevocationCheckWithoutIssuer(): void + { + $loginData = [ + 'iss' => null, + 'sub' => 'user-1', + 'sid' => 'op-session-1', + RequestDataHandler::KEY_LOGGED_IN_AT => time() - 60, + ]; + $this->sessionStoreMock->method('get') + ->with(RequestDataHandler::KEY_LOGIN_DATA) + ->willReturn($loginData); + + $this->loginRevocationRegistryMock->expects($this->never())->method('isSessionRevoked'); + $this->loginRevocationRegistryMock->expects($this->never())->method('isSubjectRevoked'); + + $this->assertSame($loginData, $this->sut()->getLoginData()); + } } From 2f706440612a6dcd1dc7daa77ab68ad50975c1f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 5 Jul 2026 17:12:22 +0200 Subject: [PATCH 02/10] Lint --- src/DynamicallyRegisteredClient.php | 2 +- src/FederatedClient.php | 4 ---- tests/Oidc/DynamicallyRegisteredClientTest.php | 10 +++++----- tests/Oidc/Helpers/HttpHelperTest.php | 3 ++- tests/Oidc/Logout/CacheLoginRevocationRegistryTest.php | 5 ++--- tests/Oidc/PreRegisteredClientTest.php | 4 ++-- tests/Oidc/Protocol/RequestDataHandlerTest.php | 3 +-- 7 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php index a060160..3a66994 100644 --- a/src/DynamicallyRegisteredClient.php +++ b/src/DynamicallyRegisteredClient.php @@ -809,7 +809,7 @@ public function handleBackchannelLogoutRequest( $clientId = $this->loadRegistrationData()?->getClientId(); - if (!is_string($clientId) || $clientId === '') { + if ($clientId === null) { throw new OidcClientException( 'No persisted client registration found, so the logout token audience can not be validated.', ); diff --git a/src/FederatedClient.php b/src/FederatedClient.php index d106db8..13bfc25 100644 --- a/src/FederatedClient.php +++ b/src/FederatedClient.php @@ -1050,10 +1050,6 @@ public function handleBackchannelLogoutRequest( // the signature) to learn which OP issued it. $issuer = $this->core->logoutTokenFactory()->fromToken($logoutToken)->getIssuer(); - if ($issuer === '') { - throw new OidcClientException('Logout token issuer claim is empty.'); - } - $opResolvedMetadata = $this->resolveOpMetadata($issuer); $opJwksUri = $opResolvedMetadata[ClaimsEnum::JwksUri->value] ?? null; diff --git a/tests/Oidc/DynamicallyRegisteredClientTest.php b/tests/Oidc/DynamicallyRegisteredClientTest.php index 27f54cd..5834808 100644 --- a/tests/Oidc/DynamicallyRegisteredClientTest.php +++ b/tests/Oidc/DynamicallyRegisteredClientTest.php @@ -1028,16 +1028,16 @@ public function testThrowsOnConflictingBackchannelLogoutUriOverride(): void $this->expectExceptionMessage('backchannel_logout_uri'); $this->sut( - backchannelLogoutUri: 'https://rp.example.org/backchannel-logout', additionalClientMetadata: ['backchannel_logout_uri' => 'https://rp.example.org/other'], + backchannelLogoutUri: 'https://rp.example.org/backchannel-logout', ); } public function testAllowsMatchingBackchannelLogoutUriOverride(): void { $clientMetadata = $this->sut( - backchannelLogoutUri: 'https://rp.example.org/backchannel-logout', additionalClientMetadata: ['backchannel_logout_uri' => 'https://rp.example.org/backchannel-logout'], + backchannelLogoutUri: 'https://rp.example.org/backchannel-logout', )->buildClientRegistrationMetadata(); $this->assertSame('https://rp.example.org/backchannel-logout', $clientMetadata['backchannel_logout_uri']); @@ -1052,7 +1052,7 @@ public function testHandleBackchannelLogoutRequestSuccess(): void ->with($request) ->willReturn('logout-token'); - $this->metadataMock->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], ]); @@ -1093,7 +1093,7 @@ public function testHandleBackchannelLogoutRequestRespondsWith400WithoutRegistra { $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); - $this->metadataMock->method('get')->willReturnMap([ + $this->metadataMock->expects($this->once())->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], ]); @@ -1120,7 +1120,7 @@ public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogout { $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); - $this->metadataMock->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], ]); diff --git a/tests/Oidc/Helpers/HttpHelperTest.php b/tests/Oidc/Helpers/HttpHelperTest.php index d6605c5..b498d13 100644 --- a/tests/Oidc/Helpers/HttpHelperTest.php +++ b/tests/Oidc/Helpers/HttpHelperTest.php @@ -7,6 +7,7 @@ use Cicnavi\Oidc\Helpers\HttpHelper; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; #[CoversClass(HttpHelper::class)] @@ -224,7 +225,7 @@ public function testDispatchBackchannelLogoutResponsePopulatesErrorResponse(): v $headers = []; $response->expects($this->exactly(2)) ->method('withHeader') - ->willReturnCallback(function (string $name, string $value) use (&$headers, $response) { + ->willReturnCallback(function (string $name, string $value) use (&$headers, $response): MockObject { $headers[$name] = $value; return $response; }); diff --git a/tests/Oidc/Logout/CacheLoginRevocationRegistryTest.php b/tests/Oidc/Logout/CacheLoginRevocationRegistryTest.php index 881455a..7e5fdb7 100644 --- a/tests/Oidc/Logout/CacheLoginRevocationRegistryTest.php +++ b/tests/Oidc/Logout/CacheLoginRevocationRegistryTest.php @@ -24,9 +24,9 @@ final class CacheLoginRevocationRegistryTest extends TestCase */ private array $cacheStorage = []; - private MockObject $cacheMock; + private MockObject&CacheInterface $cacheMock; - private MockObject $loggerMock; + private MockObject&LoggerInterface $loggerMock; protected function setUp(): void { @@ -49,7 +49,6 @@ protected function sut( ?DateInterval $revocationDuration = null, ?LoggerInterface $logger = null, ): CacheLoginRevocationRegistry { - /** @var CacheInterface $cache */ $cache ??= $this->cacheMock; $revocationDuration ??= new DateInterval('P1D'); $logger ??= $this->loggerMock; diff --git a/tests/Oidc/PreRegisteredClientTest.php b/tests/Oidc/PreRegisteredClientTest.php index da7a95e..06fdbaf 100644 --- a/tests/Oidc/PreRegisteredClientTest.php +++ b/tests/Oidc/PreRegisteredClientTest.php @@ -775,7 +775,7 @@ public function testHandleBackchannelLogoutRequestSuccess(): void ->with($request) ->willReturn('logout-token'); - $this->metadataMock->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], ]); @@ -810,7 +810,7 @@ public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogout { $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); - $this->metadataMock->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], ]); diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index b0f0d00..eb784cb 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -68,7 +68,7 @@ final class RequestDataHandlerTest extends TestCase private DateInterval $maxCacheDuration; - private MockObject $loginRevocationRegistryMock; + private MockObject&LoginRevocationRegistryInterface $loginRevocationRegistryMock; protected function setUp(): void { @@ -111,7 +111,6 @@ protected function sut( $pkceDataHandler ??= $this->pkceDataHandlerMock; $logger ??= $this->loggerMock; $maxCacheDuration ??= $this->maxCacheDuration; - /** @var LoginRevocationRegistryInterface $loginRevocationRegistry */ $loginRevocationRegistry ??= $this->loginRevocationRegistryMock; return new RequestDataHandler( From 1ea127e2b72ea7695c732a5dedeab2e959b172a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 5 Jul 2026 20:43:25 +0200 Subject: [PATCH 03/10] Limit LogoutToken algs per client metadata --- src/DynamicallyRegisteredClient.php | 24 ++--- src/FederatedClient.php | 4 + src/Helpers/MetadataHelper.php | 74 +++++++++++++++ src/PreRegisteredClient.php | 29 +++--- src/Protocol/RequestDataHandler.php | 26 ++++++ .../Oidc/DynamicallyRegisteredClientTest.php | 8 +- tests/Oidc/FederatedClientTest.php | 2 + tests/Oidc/Helpers/MetadataHelperTest.php | 93 +++++++++++++++++++ tests/Oidc/PreRegisteredClientTest.php | 8 +- .../Oidc/Protocol/RequestDataHandlerTest.php | 34 +++++++ 10 files changed, 263 insertions(+), 39 deletions(-) create mode 100644 src/Helpers/MetadataHelper.php create mode 100644 tests/Oidc/Helpers/MetadataHelperTest.php diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php index 3a66994..49b9682 100644 --- a/src/DynamicallyRegisteredClient.php +++ b/src/DynamicallyRegisteredClient.php @@ -11,6 +11,7 @@ use Cicnavi\Oidc\DataStore\PhpSessionStore; use Cicnavi\Oidc\Exceptions\OidcClientException; use Cicnavi\Oidc\Helpers\HttpHelper; +use Cicnavi\Oidc\Helpers\MetadataHelper; use Cicnavi\Oidc\Interfaces\MetadataInterface; use Cicnavi\Oidc\Protocol\ClientRegistrationHandler; use Cicnavi\Oidc\Protocol\OpMetadata; @@ -713,7 +714,7 @@ public function logout( $requestDataHandler = $this->resolveRequestDataHandler(); $endSessionEndpoint = $requestDataHandler->getLoginEndSessionEndpoint() ?? - $this->getOptionalMetadataString(ClaimsEnum::EndSessionEndpoint->value); + MetadataHelper::optionalString($this->metadata, ClaimsEnum::EndSessionEndpoint->value); if (!is_string($endSessionEndpoint)) { throw new OidcClientException( @@ -818,8 +819,12 @@ public function handleBackchannelLogoutRequest( $logoutTokenJws = $requestDataHandler->validateLogoutToken( logoutToken: $logoutToken, jwksUri: $opJwksUri, - expectedIssuer: $this->getOptionalMetadataString(ClaimsEnum::Issuer->value), + expectedIssuer: MetadataHelper::optionalString($this->metadata, ClaimsEnum::Issuer->value), expectedClientId: $clientId, + allowedSigningAlgorithms: MetadataHelper::optionalStringList( + $this->metadata, + ClaimsEnum::IdTokenSigningAlgValuesSupported->value, + ), ); $requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); @@ -906,21 +911,6 @@ protected function resolveRequestDataHandler(): RequestDataHandler ); } - /** - * 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/FederatedClient.php b/src/FederatedClient.php index 13bfc25..d71e64b 100644 --- a/src/FederatedClient.php +++ b/src/FederatedClient.php @@ -12,6 +12,7 @@ use Cicnavi\Oidc\Exceptions\OidcClientException; use Cicnavi\Oidc\Federation\RelyingPartyConfig; use Cicnavi\Oidc\Helpers\HttpHelper; +use Cicnavi\Oidc\Helpers\MetadataHelper; use Cicnavi\Oidc\Protocol\RequestDataHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -1063,6 +1064,9 @@ public function handleBackchannelLogoutRequest( jwksUri: $opJwksUri, expectedIssuer: $issuer, expectedClientId: $this->entityConfig->getEntityId(), + allowedSigningAlgorithms: MetadataHelper::toNonEmptyStringListOrNull( + $opResolvedMetadata[ClaimsEnum::IdTokenSigningAlgValuesSupported->value] ?? null, + ), ); $this->requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); diff --git a/src/Helpers/MetadataHelper.php b/src/Helpers/MetadataHelper.php new file mode 100644 index 0000000..68b94f8 --- /dev/null +++ b/src/Helpers/MetadataHelper.php @@ -0,0 +1,74 @@ +get($key); + } catch (OidcClientException) { + return null; + } + + return (is_string($value) && $value !== '') ? $value : null; + } + + /** + * Read an optional list of non-empty strings for the given key from OP + * metadata (e.g. 'id_token_signing_alg_values_supported'), returning null + * when the key is not advertised or does not hold a non-empty list of + * strings. + * + * @return ?non-empty-list + */ + public static function optionalStringList(MetadataInterface $metadata, string $key): ?array + { + try { + $value = $metadata->get($key); + } catch (OidcClientException) { + return null; + } + + return self::toNonEmptyStringListOrNull($value); + } + + /** + * Coerce a value to a non-empty list of non-empty strings, or null when + * it is not an array or holds no non-empty strings. Useful for reading a + * string-list claim from already-resolved metadata (e.g. a federation + * Trust Chain's resolved OP metadata array). + * + * @return ?non-empty-list + */ + public static function toNonEmptyStringListOrNull(mixed $value): ?array + { + if (!is_array($value)) { + return null; + } + + $strings = array_values(array_filter( + $value, + static fn(mixed $item): bool => is_string($item) && $item !== '', + )); + + return $strings === [] ? null : $strings; + } +} diff --git a/src/PreRegisteredClient.php b/src/PreRegisteredClient.php index 7dbec3f..9563306 100644 --- a/src/PreRegisteredClient.php +++ b/src/PreRegisteredClient.php @@ -11,6 +11,7 @@ use Cicnavi\Oidc\DataStore\PhpSessionStore; use Cicnavi\Oidc\Exceptions\OidcClientException; use Cicnavi\Oidc\Helpers\HttpHelper; +use Cicnavi\Oidc\Helpers\MetadataHelper; use Cicnavi\Oidc\Interfaces\MetadataInterface; use Cicnavi\Oidc\Protocol\OpMetadata; use Cicnavi\Oidc\Protocol\RequestDataHandler; @@ -343,7 +344,10 @@ public function getUserData(?ServerRequestInterface $request = null): array useNonce: $this->useNonce, fetchUserinfoClaims: $this->fetchUserinfoClaims, expectedIssuer: $expectedIssuer, - opEndSessionEndpoint: $this->getOptionalMetadataString(ClaimsEnum::EndSessionEndpoint->value), + opEndSessionEndpoint: MetadataHelper::optionalString( + $this->metadata, + ClaimsEnum::EndSessionEndpoint->value, + ), ); } @@ -389,7 +393,7 @@ public function logout( ?ResponseInterface $response = null, ): ?ResponseInterface { $endSessionEndpoint = $this->requestDataHandler->getLoginEndSessionEndpoint() ?? - $this->getOptionalMetadataString(ClaimsEnum::EndSessionEndpoint->value); + MetadataHelper::optionalString($this->metadata, ClaimsEnum::EndSessionEndpoint->value); if (!is_string($endSessionEndpoint)) { throw new OidcClientException( @@ -485,8 +489,12 @@ public function handleBackchannelLogoutRequest( $logoutTokenJws = $this->requestDataHandler->validateLogoutToken( logoutToken: $logoutToken, jwksUri: $opJwksUri, - expectedIssuer: $this->getOptionalMetadataString(ClaimsEnum::Issuer->value), + expectedIssuer: MetadataHelper::optionalString($this->metadata, ClaimsEnum::Issuer->value), expectedClientId: $this->clientId, + allowedSigningAlgorithms: MetadataHelper::optionalStringList( + $this->metadata, + ClaimsEnum::IdTokenSigningAlgValuesSupported->value, + ), ); $this->requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); @@ -527,21 +535,6 @@ 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 2c91ea2..b6f967c 100644 --- a/src/Protocol/RequestDataHandler.php +++ b/src/Protocol/RequestDataHandler.php @@ -1276,6 +1276,13 @@ public function parseBackchannelLogoutRequest(?ServerRequestInterface $request = * match it. * @param ?string $expectedClientId When provided, the 'aud' claim must * contain it. + * @param ?string[] $allowedSigningAlgorithms When a non-empty list is + * provided, the logout token 'alg' header must be one of these values + * (typically the OP's advertised 'id_token_signing_alg_values_supported'). + * This applies "the same restrictions on the algorithms" as ID Token + * validation, per specification section 2.6, so a logout token that is + * validly signed but with an unexpected algorithm is rejected. 'none' is + * never accepted regardless (it never verifies). * @param ?\DateInterval $logoutTokenMaxAge Maximum accepted logout token * age ('iat' claim freshness), null to disable the check. Default is 5 * minutes. @@ -1290,6 +1297,7 @@ public function validateLogoutToken( string $jwksUri, ?string $expectedIssuer = null, ?string $expectedClientId = null, + ?array $allowedSigningAlgorithms = null, ?\DateInterval $logoutTokenMaxAge = new \DateInterval(self::DEFAULT_LOGOUT_TOKEN_MAX_AGE), bool $checkJtiReplay = true, bool $refreshCache = false, @@ -1304,6 +1312,23 @@ public function validateLogoutToken( throw new OidcClientException($error, (int) $throwable->getCode(), $throwable); } + // Enforce the expected signing algorithm(s) before verifying the + // signature, so a validly signed but unexpected-algorithm token is + // rejected (specification section 2.6) without a needless JWKS + // refresh retry. + if (is_array($allowedSigningAlgorithms) && $allowedSigningAlgorithms !== []) { + $alg = $logoutTokenJws->getAlgorithm(); + if (!in_array($alg, $allowedSigningAlgorithms, true)) { + $error = sprintf( + 'Logout token signing algorithm "%s" is not among the expected algorithms (%s).', + $alg ?? 'none', + implode(', ', $allowedSigningAlgorithms), + ); + $this->logger?->error($error); + throw new OidcClientException($error); + } + } + try { $logoutTokenJws->verifyWithKeySet($jwks); } catch (Throwable $throwable) { @@ -1323,6 +1348,7 @@ public function validateLogoutToken( jwksUri: $jwksUri, expectedIssuer: $expectedIssuer, expectedClientId: $expectedClientId, + allowedSigningAlgorithms: $allowedSigningAlgorithms, logoutTokenMaxAge: $logoutTokenMaxAge, checkJtiReplay: $checkJtiReplay, refreshCache: true, diff --git a/tests/Oidc/DynamicallyRegisteredClientTest.php b/tests/Oidc/DynamicallyRegisteredClientTest.php index 5834808..6a0e569 100644 --- a/tests/Oidc/DynamicallyRegisteredClientTest.php +++ b/tests/Oidc/DynamicallyRegisteredClientTest.php @@ -24,6 +24,7 @@ #[UsesClass(\Cicnavi\Oidc\DataStore\DataHandlers\AbstractDataHandler::class)] #[UsesClass(\Cicnavi\Oidc\Protocol\RequestDataHandler::class)] #[UsesClass(\Cicnavi\Oidc\Helpers\HttpHelper::class)] +#[UsesClass(\Cicnavi\Oidc\Helpers\MetadataHelper::class)] final class DynamicallyRegisteredClientTest extends TestCase { protected string $opConfigurationUrl = 'https://op.example.org/.well-known/openid-configuration'; @@ -1052,9 +1053,10 @@ public function testHandleBackchannelLogoutRequestSuccess(): void ->with($request) ->willReturn('logout-token'); - $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], + ['id_token_signing_alg_values_supported', ['RS256']], ]); // Persisted client registration provides the expected audience. @@ -1072,6 +1074,7 @@ public function testHandleBackchannelLogoutRequestSuccess(): void 'https://op.example.org/jwks', 'https://op.example.org', 'registered-client-id', + ['RS256'], ) ->willReturn($logoutTokenJws); @@ -1120,9 +1123,10 @@ public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogout { $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); - $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], + ['id_token_signing_alg_values_supported', ['RS256']], ]); $this->registrationStoreMock->method('get')->willReturn([ diff --git a/tests/Oidc/FederatedClientTest.php b/tests/Oidc/FederatedClientTest.php index c460576..5bbfc54 100644 --- a/tests/Oidc/FederatedClientTest.php +++ b/tests/Oidc/FederatedClientTest.php @@ -15,6 +15,7 @@ use Cicnavi\Oidc\Federation\EntityConfig; use Cicnavi\Oidc\Federation\RelyingPartyConfig; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; @@ -59,6 +60,7 @@ #[CoversClass(FederatedClient::class)] #[CoversClass(HttpHelper::class)] +#[UsesClass(\Cicnavi\Oidc\Helpers\MetadataHelper::class)] final class FederatedClientTest extends TestCase { private MockObject $entityConfigMock; diff --git a/tests/Oidc/Helpers/MetadataHelperTest.php b/tests/Oidc/Helpers/MetadataHelperTest.php new file mode 100644 index 0000000..14896ca --- /dev/null +++ b/tests/Oidc/Helpers/MetadataHelperTest.php @@ -0,0 +1,93 @@ +createMock(MetadataInterface::class); + + if ($throw) { + $metadata->method('get')->willThrowException(new OidcClientException('Not advertised.')); + } else { + $metadata->method('get')->with($key)->willReturn($value); + } + + return $metadata; + } + + public function testOptionalStringReturnsNonEmptyString(): void + { + $metadata = $this->metadata('issuer', 'https://op.example.org'); + + $this->assertSame('https://op.example.org', MetadataHelper::optionalString($metadata, 'issuer')); + } + + public function testOptionalStringReturnsNullForEmptyString(): void + { + $metadata = $this->metadata('issuer', ''); + + $this->assertNull(MetadataHelper::optionalString($metadata, 'issuer')); + } + + public function testOptionalStringReturnsNullForNonString(): void + { + $metadata = $this->metadata('issuer', ['not', 'a', 'string']); + + $this->assertNull(MetadataHelper::optionalString($metadata, 'issuer')); + } + + public function testOptionalStringReturnsNullWhenKeyNotAdvertised(): void + { + $metadata = $this->metadata('issuer', null, throw: true); + + $this->assertNull(MetadataHelper::optionalString($metadata, 'issuer')); + } + + public function testOptionalStringListReturnsNonEmptyStrings(): void + { + $metadata = $this->metadata('id_token_signing_alg_values_supported', ['RS256', 'ES256']); + + $this->assertSame( + ['RS256', 'ES256'], + MetadataHelper::optionalStringList($metadata, 'id_token_signing_alg_values_supported'), + ); + } + + public function testOptionalStringListReturnsNullWhenKeyNotAdvertised(): void + { + $metadata = $this->metadata('id_token_signing_alg_values_supported', null, throw: true); + + $this->assertNull( + MetadataHelper::optionalStringList($metadata, 'id_token_signing_alg_values_supported'), + ); + } + + public function testToNonEmptyStringListOrNullFiltersOutNonStringsAndReindexes(): void + { + $this->assertSame( + ['RS256', 'ES256'], + MetadataHelper::toNonEmptyStringListOrNull(['RS256', 123, '', null, 'ES256']), + ); + } + + public function testToNonEmptyStringListOrNullReturnsNullForNonArray(): void + { + $this->assertNull(MetadataHelper::toNonEmptyStringListOrNull('RS256')); + } + + public function testToNonEmptyStringListOrNullReturnsNullForEmptyResult(): void + { + $this->assertNull(MetadataHelper::toNonEmptyStringListOrNull([123, '', null])); + } +} diff --git a/tests/Oidc/PreRegisteredClientTest.php b/tests/Oidc/PreRegisteredClientTest.php index 06fdbaf..663d3d2 100644 --- a/tests/Oidc/PreRegisteredClientTest.php +++ b/tests/Oidc/PreRegisteredClientTest.php @@ -24,6 +24,7 @@ #[CoversClass(PreRegisteredClient::class)] #[UsesClass(HttpHelper::class)] +#[UsesClass(\Cicnavi\Oidc\Helpers\MetadataHelper::class)] final class PreRegisteredClientTest extends TestCase { private string $opConfigrationUrl; @@ -775,9 +776,10 @@ public function testHandleBackchannelLogoutRequestSuccess(): void ->with($request) ->willReturn('logout-token'); - $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], + ['id_token_signing_alg_values_supported', ['RS256']], ]); $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); @@ -789,6 +791,7 @@ public function testHandleBackchannelLogoutRequestSuccess(): void 'https://op.example.org/jwks', 'https://op.example.org', $this->clientId, + ['RS256'], ) ->willReturn($logoutTokenJws); @@ -810,9 +813,10 @@ public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogout { $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); - $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], + ['id_token_signing_alg_values_supported', ['RS256']], ]); $this->requestDataHandlerMock->method('validateLogoutToken') diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index eb784cb..6b293c4 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -1841,6 +1841,40 @@ public function testValidateLogoutTokenThrowsOnAudienceMismatch(): void ); } + public function testValidateLogoutTokenThrowsOnDisallowedSigningAlgorithm(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + // Validly signed, but with an algorithm the OP does not advertise. + $logoutTokenJws->method('getAlgorithm')->willReturn('ES256'); + // The algorithm is rejected before the signature is even verified. + $logoutTokenJws->expects($this->never())->method('verifyWithKeySet'); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('is not among the expected algorithms'); + + $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + allowedSigningAlgorithms: ['RS256'], + ); + } + + public function testValidateLogoutTokenAcceptsAllowedSigningAlgorithm(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $this->configureValidLogoutToken($logoutTokenJws); + $logoutTokenJws->method('getAlgorithm')->willReturn('RS256'); + $logoutTokenJws->expects($this->once())->method('verifyWithKeySet'); + + $result = $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + allowedSigningAlgorithms: ['RS256'], + ); + + $this->assertSame($logoutTokenJws, $result); + } + public function testValidateLogoutTokenWarnsOnUnexpectedTypHeader(): void { $logoutTokenJws = $this->mockLogoutTokenSetup(); From 92181465d8a563efe06485fb095977ba431a88a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Sun, 5 Jul 2026 21:27:30 +0200 Subject: [PATCH 04/10] Tighten alg check for client --- docs/2-Pre-Registered-Client.md | 16 +++- src/DynamicallyRegisteredClient.php | 13 ++- src/FederatedClient.php | 11 ++- src/Helpers/MetadataHelper.php | 45 +-------- src/PreRegisteredClient.php | 12 ++- src/Protocol/RequestDataHandler.php | 92 ++++++++++++------- .../Oidc/DynamicallyRegisteredClientTest.php | 8 +- tests/Oidc/FederatedClientTest.php | 2 - tests/Oidc/Helpers/MetadataHelperTest.php | 37 -------- tests/Oidc/PreRegisteredClientTest.php | 8 +- .../Oidc/Protocol/RequestDataHandlerTest.php | 50 ++++++++-- 11 files changed, 147 insertions(+), 147 deletions(-) diff --git a/docs/2-Pre-Registered-Client.md b/docs/2-Pre-Registered-Client.md index 9c133c9..32d4946 100644 --- a/docs/2-Pre-Registered-Client.md +++ b/docs/2-Pre-Registered-Client.md @@ -306,13 +306,19 @@ $oidcClient->handleBackchannelLogoutRequest(); ``` The method validates the logout token from the request (signature against -the OP JWKS, issuer, audience, required claims, freshness, and `jti` replay -detection), records the requested login revocation, and emits the proper -HTTP response itself (200 when the logout was performed, 400 with a JSON -error body when not). It also accepts an optional PSR-7 server request to -read from, and an optional PSR-7 `response` instance which will be +the OP JWKS, signing algorithm, issuer, audience, required claims, freshness, +and `jti` replay detection), records the requested login revocation, and +emits the proper HTTP response itself (200 when the logout was performed, 400 +with a JSON error body when not). It also accepts an optional PSR-7 server +request to read from, and an optional PSR-7 `response` instance which will be populated and returned instead of emitting output directly. +The logout token must be signed with the algorithm the OP uses for this +client's ID tokens (the `id_token_signed_response_alg` client metadata), +which defaults to `RS256` per OpenID Connect. If your OP signs tokens with a +different algorithm, set the `idTokenSignedResponseAlg` constructor parameter +accordingly (or to `null` to accept any supported algorithm). + ### How the affected login is terminated A back-channel logout request arrives outside the context of the End-User's diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php index 49b9682..bb680de 100644 --- a/src/DynamicallyRegisteredClient.php +++ b/src/DynamicallyRegisteredClient.php @@ -157,6 +157,13 @@ class DynamicallyRegisteredClient * (whether the OP should include a 'sid' claim in logout tokens sent to * this client), null to leave it out. Only registered when * $backchannelLogoutUri is provided. + * @param ?string $idTokenSignedResponseAlg The JWS algorithm the OP uses + * to sign this client's ID tokens and OIDC Back-Channel Logout tokens. + * Back-Channel Logout tokens signed with a different algorithm are + * rejected. Defaults to 'RS256' (the OpenID Connect default). Set to null + * to accept any supported algorithm. When registering a specific + * 'id_token_signed_response_alg' via $additionalClientMetadata, set this + * to the same value. * * For other parameters, refer to PreRegisteredClient - they are forwarded * to the underlying client instance which is built after registration. @@ -211,6 +218,7 @@ public function __construct( protected readonly array $postLogoutRedirectUris = [], protected readonly ?string $backchannelLogoutUri = null, protected readonly ?bool $backchannelLogoutSessionRequired = null, + protected readonly ?string $idTokenSignedResponseAlg = SignatureAlgorithmEnum::RS256->value, ) { $this->cache = $cache ?? new FileCache( 'odrcpc-' . md5($this->opConfigurationUrl . '|' . $this->redirectUri), @@ -821,10 +829,7 @@ public function handleBackchannelLogoutRequest( jwksUri: $opJwksUri, expectedIssuer: MetadataHelper::optionalString($this->metadata, ClaimsEnum::Issuer->value), expectedClientId: $clientId, - allowedSigningAlgorithms: MetadataHelper::optionalStringList( - $this->metadata, - ClaimsEnum::IdTokenSigningAlgValuesSupported->value, - ), + expectedSigningAlgorithm: $this->idTokenSignedResponseAlg, ); $requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); diff --git a/src/FederatedClient.php b/src/FederatedClient.php index d71e64b..0d8c2b6 100644 --- a/src/FederatedClient.php +++ b/src/FederatedClient.php @@ -12,7 +12,6 @@ use Cicnavi\Oidc\Exceptions\OidcClientException; use Cicnavi\Oidc\Federation\RelyingPartyConfig; use Cicnavi\Oidc\Helpers\HttpHelper; -use Cicnavi\Oidc\Helpers\MetadataHelper; use Cicnavi\Oidc\Protocol\RequestDataHandler; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -99,6 +98,11 @@ class FederatedClient * artifacts will be cached. Defaults to 6 hours. * @param \DateInterval $timestampValidationLeeway Leeway used for timestamp * validation. Defaults to 1 minute. + * @param ?string $idTokenSignedResponseAlg The JWS algorithm the OP uses + * to sign this RP's ID tokens and OIDC Back-Channel Logout tokens. + * Back-Channel Logout tokens signed with a different algorithm are + * rejected. Defaults to 'RS256' (the OpenID Connect default). Set to null + * to accept any supported algorithm. * * @throws CacheException */ @@ -151,6 +155,7 @@ public function __construct( int $maxDiscoveryDepth = 10, ?EntityCollectionStoreInterface $entityCollectionStore = null, protected readonly ParModeEnum $parMode = ParModeEnum::Auto, + protected readonly ?string $idTokenSignedResponseAlg = SignatureAlgorithmEnum::RS256->value, ) { $this->validateResponseMode($this->responseMode); $this->cache = $cache ?? new FileCache('ofacpc-' . md5($this->entityConfig->getEntityId())); @@ -1064,9 +1069,7 @@ public function handleBackchannelLogoutRequest( jwksUri: $opJwksUri, expectedIssuer: $issuer, expectedClientId: $this->entityConfig->getEntityId(), - allowedSigningAlgorithms: MetadataHelper::toNonEmptyStringListOrNull( - $opResolvedMetadata[ClaimsEnum::IdTokenSigningAlgValuesSupported->value] ?? null, - ), + expectedSigningAlgorithm: $this->idTokenSignedResponseAlg, ); $this->requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); diff --git a/src/Helpers/MetadataHelper.php b/src/Helpers/MetadataHelper.php index 68b94f8..4480b10 100644 --- a/src/Helpers/MetadataHelper.php +++ b/src/Helpers/MetadataHelper.php @@ -8,8 +8,8 @@ use Cicnavi\Oidc\Interfaces\MetadataInterface; /** - * Helpers for reading optional values from OpenID Provider (OP) metadata, - * used by the client implementations so the coercion logic lives in one place. + * Helper for reading optional values from OpenID Provider (OP) metadata, used + * by the client implementations so the coercion logic lives in one place. * * @see \Cicnavi\Tests\Oidc\Helpers\MetadataHelperTest */ @@ -30,45 +30,4 @@ public static function optionalString(MetadataInterface $metadata, string $key): return (is_string($value) && $value !== '') ? $value : null; } - - /** - * Read an optional list of non-empty strings for the given key from OP - * metadata (e.g. 'id_token_signing_alg_values_supported'), returning null - * when the key is not advertised or does not hold a non-empty list of - * strings. - * - * @return ?non-empty-list - */ - public static function optionalStringList(MetadataInterface $metadata, string $key): ?array - { - try { - $value = $metadata->get($key); - } catch (OidcClientException) { - return null; - } - - return self::toNonEmptyStringListOrNull($value); - } - - /** - * Coerce a value to a non-empty list of non-empty strings, or null when - * it is not an array or holds no non-empty strings. Useful for reading a - * string-list claim from already-resolved metadata (e.g. a federation - * Trust Chain's resolved OP metadata array). - * - * @return ?non-empty-list - */ - public static function toNonEmptyStringListOrNull(mixed $value): ?array - { - if (!is_array($value)) { - return null; - } - - $strings = array_values(array_filter( - $value, - static fn(mixed $item): bool => is_string($item) && $item !== '', - )); - - return $strings === [] ? null : $strings; - } } diff --git a/src/PreRegisteredClient.php b/src/PreRegisteredClient.php index 9563306..6a4dbd8 100644 --- a/src/PreRegisteredClient.php +++ b/src/PreRegisteredClient.php @@ -93,6 +93,12 @@ class PreRegisteredClient * send HTTP requests. * @param Core|null $core Core library instance. If not provided, a new one * will be built using provided options. + * @param ?string $idTokenSignedResponseAlg The JWS algorithm the OP uses + * to sign this client's ID tokens and OIDC Back-Channel Logout tokens + * (the 'id_token_signed_response_alg' client metadata). Back-Channel + * Logout tokens signed with a different algorithm are rejected. Defaults + * to 'RS256' (the OpenID Connect default). Set to null to accept any + * supported algorithm. * @throws CacheException If cache could not be initialized. * @throws OidcClientException If cache could not be reinitialized. */ @@ -136,6 +142,7 @@ public function __construct( protected readonly ?ResponseModesEnum $responseMode = null, ?RequestDataHandler $requestDataHandler = null, protected readonly ParModeEnum $parMode = ParModeEnum::Auto, + protected readonly ?string $idTokenSignedResponseAlg = SignatureAlgorithmEnum::RS256->value, ) { $this->validateResponseMode($this->responseMode); @@ -491,10 +498,7 @@ public function handleBackchannelLogoutRequest( jwksUri: $opJwksUri, expectedIssuer: MetadataHelper::optionalString($this->metadata, ClaimsEnum::Issuer->value), expectedClientId: $this->clientId, - allowedSigningAlgorithms: MetadataHelper::optionalStringList( - $this->metadata, - ClaimsEnum::IdTokenSigningAlgValuesSupported->value, - ), + expectedSigningAlgorithm: $this->idTokenSignedResponseAlg, ); $this->requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); diff --git a/src/Protocol/RequestDataHandler.php b/src/Protocol/RequestDataHandler.php index b6f967c..bf3ad03 100644 --- a/src/Protocol/RequestDataHandler.php +++ b/src/Protocol/RequestDataHandler.php @@ -1276,13 +1276,13 @@ public function parseBackchannelLogoutRequest(?ServerRequestInterface $request = * match it. * @param ?string $expectedClientId When provided, the 'aud' claim must * contain it. - * @param ?string[] $allowedSigningAlgorithms When a non-empty list is - * provided, the logout token 'alg' header must be one of these values - * (typically the OP's advertised 'id_token_signing_alg_values_supported'). - * This applies "the same restrictions on the algorithms" as ID Token - * validation, per specification section 2.6, so a logout token that is - * validly signed but with an unexpected algorithm is rejected. 'none' is - * never accepted regardless (it never verifies). + * @param ?string $expectedSigningAlgorithm When provided, the logout + * token 'alg' header must equal this value (the RP's + * 'id_token_signed_response_alg', which the OP also uses to sign this + * client's logout tokens). This applies "the same restrictions on the + * algorithms" as ID Token validation, per specification section 2.6, so + * a logout token that is validly signed but with a different algorithm + * than expected is rejected. * @param ?\DateInterval $logoutTokenMaxAge Maximum accepted logout token * age ('iat' claim freshness), null to disable the check. Default is 5 * minutes. @@ -1297,7 +1297,7 @@ public function validateLogoutToken( string $jwksUri, ?string $expectedIssuer = null, ?string $expectedClientId = null, - ?array $allowedSigningAlgorithms = null, + ?string $expectedSigningAlgorithm = null, ?\DateInterval $logoutTokenMaxAge = new \DateInterval(self::DEFAULT_LOGOUT_TOKEN_MAX_AGE), bool $checkJtiReplay = true, bool $refreshCache = false, @@ -1312,21 +1312,30 @@ public function validateLogoutToken( throw new OidcClientException($error, (int) $throwable->getCode(), $throwable); } - // Enforce the expected signing algorithm(s) before verifying the - // signature, so a validly signed but unexpected-algorithm token is - // rejected (specification section 2.6) without a needless JWKS - // refresh retry. - if (is_array($allowedSigningAlgorithms) && $allowedSigningAlgorithms !== []) { - $alg = $logoutTokenJws->getAlgorithm(); - if (!in_array($alg, $allowedSigningAlgorithms, true)) { - $error = sprintf( - 'Logout token signing algorithm "%s" is not among the expected algorithms (%s).', - $alg ?? 'none', - implode(', ', $allowedSigningAlgorithms), - ); - $this->logger?->error($error); - throw new OidcClientException($error); - } + // Validate the 'alg' Header Parameter (specification section 2.6), + // before verifying the signature so an invalid-algorithm token is + // rejected without a needless JWKS refresh retry. + $alg = $logoutTokenJws->getAlgorithm(); + + // An 'alg' of 'none' MUST NOT be used for Logout Tokens - they must be + // signed (a signed JWT also always carries an 'alg' header). + if ($alg === null || strcasecmp($alg, 'none') === 0) { + $error = 'Logout token is unsigned or uses the "none" algorithm, which is not allowed.'; + $this->logger?->error($error); + throw new OidcClientException($error); + } + + // Like ID Tokens, the algorithm the OP uses is the single one the RP + // registered ('id_token_signed_response_alg', default RS256) - not the + // OP's broad advertised 'id_token_signing_alg_values_supported' set. + if ($expectedSigningAlgorithm !== null && $alg !== $expectedSigningAlgorithm) { + $error = sprintf( + 'Logout token signing algorithm "%s" does not match the expected algorithm "%s".', + $alg, + $expectedSigningAlgorithm, + ); + $this->logger?->error($error); + throw new OidcClientException($error); } try { @@ -1348,7 +1357,7 @@ public function validateLogoutToken( jwksUri: $jwksUri, expectedIssuer: $expectedIssuer, expectedClientId: $expectedClientId, - allowedSigningAlgorithms: $allowedSigningAlgorithms, + expectedSigningAlgorithm: $expectedSigningAlgorithm, logoutTokenMaxAge: $logoutTokenMaxAge, checkJtiReplay: $checkJtiReplay, refreshCache: true, @@ -1369,14 +1378,33 @@ public function validateLogoutToken( } } - // Validate Audience (aud) - if ($expectedClientId !== null && !in_array($expectedClientId, $logoutTokenJws->getAudience(), true)) { - $error = sprintf( - 'Logout token audience claim does not contain expected client ID "%s".', - $expectedClientId, - ); - $this->logger?->error($error); - throw new OidcClientException($error); + // Validate Audience (aud) and Authorized Party (azp) the same way as + // for ID Tokens (specification section 2.6): the audience must contain + // the client ID, and when there are multiple audiences an 'azp' claim + // matching the client ID must be present. + if ($expectedClientId !== null) { + $aud = $logoutTokenJws->getAudience(); + + if (!in_array($expectedClientId, $aud, true)) { + $error = sprintf( + 'Logout token audience claim does not contain expected client ID "%s".', + $expectedClientId, + ); + $this->logger?->error($error); + throw new OidcClientException($error); + } + + if (count($aud) > 1) { + $azp = $logoutTokenJws->getPayloadClaim(ClaimsEnum::Azp->value); + if ($azp !== $expectedClientId) { + $error = sprintf( + 'Logout token authorized party (azp) claim does not match expected client ID "%s".', + $expectedClientId, + ); + $this->logger?->error($error); + throw new OidcClientException($error); + } + } } // Explicit typing ('typ' header of 'logout+jwt') is only RECOMMENDED diff --git a/tests/Oidc/DynamicallyRegisteredClientTest.php b/tests/Oidc/DynamicallyRegisteredClientTest.php index 6a0e569..d87e5c1 100644 --- a/tests/Oidc/DynamicallyRegisteredClientTest.php +++ b/tests/Oidc/DynamicallyRegisteredClientTest.php @@ -1053,10 +1053,9 @@ public function testHandleBackchannelLogoutRequestSuccess(): void ->with($request) ->willReturn('logout-token'); - $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], - ['id_token_signing_alg_values_supported', ['RS256']], ]); // Persisted client registration provides the expected audience. @@ -1074,7 +1073,7 @@ public function testHandleBackchannelLogoutRequestSuccess(): void 'https://op.example.org/jwks', 'https://op.example.org', 'registered-client-id', - ['RS256'], + 'RS256', ) ->willReturn($logoutTokenJws); @@ -1123,10 +1122,9 @@ public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogout { $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); - $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], - ['id_token_signing_alg_values_supported', ['RS256']], ]); $this->registrationStoreMock->method('get')->willReturn([ diff --git a/tests/Oidc/FederatedClientTest.php b/tests/Oidc/FederatedClientTest.php index 5bbfc54..c460576 100644 --- a/tests/Oidc/FederatedClientTest.php +++ b/tests/Oidc/FederatedClientTest.php @@ -15,7 +15,6 @@ use Cicnavi\Oidc\Federation\EntityConfig; use Cicnavi\Oidc\Federation\RelyingPartyConfig; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; @@ -60,7 +59,6 @@ #[CoversClass(FederatedClient::class)] #[CoversClass(HttpHelper::class)] -#[UsesClass(\Cicnavi\Oidc\Helpers\MetadataHelper::class)] final class FederatedClientTest extends TestCase { private MockObject $entityConfigMock; diff --git a/tests/Oidc/Helpers/MetadataHelperTest.php b/tests/Oidc/Helpers/MetadataHelperTest.php index 14896ca..8d86806 100644 --- a/tests/Oidc/Helpers/MetadataHelperTest.php +++ b/tests/Oidc/Helpers/MetadataHelperTest.php @@ -53,41 +53,4 @@ public function testOptionalStringReturnsNullWhenKeyNotAdvertised(): void $this->assertNull(MetadataHelper::optionalString($metadata, 'issuer')); } - - public function testOptionalStringListReturnsNonEmptyStrings(): void - { - $metadata = $this->metadata('id_token_signing_alg_values_supported', ['RS256', 'ES256']); - - $this->assertSame( - ['RS256', 'ES256'], - MetadataHelper::optionalStringList($metadata, 'id_token_signing_alg_values_supported'), - ); - } - - public function testOptionalStringListReturnsNullWhenKeyNotAdvertised(): void - { - $metadata = $this->metadata('id_token_signing_alg_values_supported', null, throw: true); - - $this->assertNull( - MetadataHelper::optionalStringList($metadata, 'id_token_signing_alg_values_supported'), - ); - } - - public function testToNonEmptyStringListOrNullFiltersOutNonStringsAndReindexes(): void - { - $this->assertSame( - ['RS256', 'ES256'], - MetadataHelper::toNonEmptyStringListOrNull(['RS256', 123, '', null, 'ES256']), - ); - } - - public function testToNonEmptyStringListOrNullReturnsNullForNonArray(): void - { - $this->assertNull(MetadataHelper::toNonEmptyStringListOrNull('RS256')); - } - - public function testToNonEmptyStringListOrNullReturnsNullForEmptyResult(): void - { - $this->assertNull(MetadataHelper::toNonEmptyStringListOrNull([123, '', null])); - } } diff --git a/tests/Oidc/PreRegisteredClientTest.php b/tests/Oidc/PreRegisteredClientTest.php index 663d3d2..9e100b4 100644 --- a/tests/Oidc/PreRegisteredClientTest.php +++ b/tests/Oidc/PreRegisteredClientTest.php @@ -776,10 +776,9 @@ public function testHandleBackchannelLogoutRequestSuccess(): void ->with($request) ->willReturn('logout-token'); - $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], - ['id_token_signing_alg_values_supported', ['RS256']], ]); $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); @@ -791,7 +790,7 @@ public function testHandleBackchannelLogoutRequestSuccess(): void 'https://op.example.org/jwks', 'https://op.example.org', $this->clientId, - ['RS256'], + 'RS256', ) ->willReturn($logoutTokenJws); @@ -813,10 +812,9 @@ public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogout { $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); - $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], - ['id_token_signing_alg_values_supported', ['RS256']], ]); $this->requestDataHandlerMock->method('validateLogoutToken') diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index 6b293c4..9edc877 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -1734,6 +1734,7 @@ private function mockLogoutTokenSetup(): MockObject */ private function configureValidLogoutToken(MockObject $logoutTokenJws): void { + $logoutTokenJws->method('getAlgorithm')->willReturn('RS256'); $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); $logoutTokenJws->method('getAudience')->willReturn(['client-id']); $logoutTokenJws->method('getType')->willReturn('logout+jwt'); @@ -1802,6 +1803,7 @@ public function testValidateLogoutTokenRetriesOnVerificationFailure(): void public function testValidateLogoutTokenThrowsAfterRetryFailure(): void { $logoutTokenJws = $this->mockLogoutTokenSetup(); + $logoutTokenJws->method('getAlgorithm')->willReturn('RS256'); $logoutTokenJws->method('verifyWithKeySet')->willThrowException(new Exception('Sig fail')); $this->expectException(OidcClientException::class); @@ -1841,25 +1843,58 @@ public function testValidateLogoutTokenThrowsOnAudienceMismatch(): void ); } - public function testValidateLogoutTokenThrowsOnDisallowedSigningAlgorithm(): void + public function testValidateLogoutTokenThrowsOnAuthorizedPartyMismatchWithMultipleAudiences(): void { $logoutTokenJws = $this->mockLogoutTokenSetup(); - // Validly signed, but with an algorithm the OP does not advertise. + $logoutTokenJws->method('getAlgorithm')->willReturn('RS256'); + $logoutTokenJws->method('getAudience')->willReturn(['client-id', 'other-audience']); + $logoutTokenJws->method('getPayloadClaim')->with('azp')->willReturn('other-client-id'); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('authorized party (azp) claim does not match'); + + $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + expectedClientId: 'client-id', + ); + } + + public function testValidateLogoutTokenThrowsOnNoneAlgorithm(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $logoutTokenJws->method('getAlgorithm')->willReturn('none'); + // 'none' is rejected unconditionally, even without an expected algorithm. + $logoutTokenJws->expects($this->never())->method('verifyWithKeySet'); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('unsigned or uses the "none" algorithm'); + + $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + ); + } + + public function testValidateLogoutTokenThrowsOnUnexpectedSigningAlgorithm(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + // Validly signed, but with an algorithm other than the expected one. $logoutTokenJws->method('getAlgorithm')->willReturn('ES256'); // The algorithm is rejected before the signature is even verified. $logoutTokenJws->expects($this->never())->method('verifyWithKeySet'); $this->expectException(OidcClientException::class); - $this->expectExceptionMessage('is not among the expected algorithms'); + $this->expectExceptionMessage('does not match the expected algorithm'); $this->sut()->validateLogoutToken( 'logout-token', 'https://op.example.org/jwks', - allowedSigningAlgorithms: ['RS256'], + expectedSigningAlgorithm: 'RS256', ); } - public function testValidateLogoutTokenAcceptsAllowedSigningAlgorithm(): void + public function testValidateLogoutTokenAcceptsExpectedSigningAlgorithm(): void { $logoutTokenJws = $this->mockLogoutTokenSetup(); $this->configureValidLogoutToken($logoutTokenJws); @@ -1869,7 +1904,7 @@ public function testValidateLogoutTokenAcceptsAllowedSigningAlgorithm(): void $result = $this->sut()->validateLogoutToken( 'logout-token', 'https://op.example.org/jwks', - allowedSigningAlgorithms: ['RS256'], + expectedSigningAlgorithm: 'RS256', ); $this->assertSame($logoutTokenJws, $result); @@ -1878,6 +1913,7 @@ public function testValidateLogoutTokenAcceptsAllowedSigningAlgorithm(): void public function testValidateLogoutTokenWarnsOnUnexpectedTypHeader(): void { $logoutTokenJws = $this->mockLogoutTokenSetup(); + $logoutTokenJws->method('getAlgorithm')->willReturn('RS256'); $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); $logoutTokenJws->method('getAudience')->willReturn(['client-id']); $logoutTokenJws->method('getType')->willReturn('JWT'); @@ -1895,6 +1931,7 @@ public function testValidateLogoutTokenWarnsOnUnexpectedTypHeader(): void public function testValidateLogoutTokenThrowsOnStaleIssuedAt(): void { $logoutTokenJws = $this->mockLogoutTokenSetup(); + $logoutTokenJws->method('getAlgorithm')->willReturn('RS256'); $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); $logoutTokenJws->method('getAudience')->willReturn(['client-id']); $logoutTokenJws->method('getType')->willReturn('logout+jwt'); @@ -1912,6 +1949,7 @@ public function testValidateLogoutTokenThrowsOnStaleIssuedAt(): void public function testValidateLogoutTokenAcceptsStaleIssuedAtWhenMaxAgeDisabled(): void { $logoutTokenJws = $this->mockLogoutTokenSetup(); + $logoutTokenJws->method('getAlgorithm')->willReturn('RS256'); $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); $logoutTokenJws->method('getAudience')->willReturn(['client-id']); $logoutTokenJws->method('getType')->willReturn('logout+jwt'); From c9be0290805c933042753df16e6568c415b140df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 6 Jul 2026 09:35:31 +0200 Subject: [PATCH 05/10] Record jti after revocation --- src/Protocol/RequestDataHandler.php | 59 ++++++++++++------- .../Oidc/Protocol/RequestDataHandlerTest.php | 38 ++++++++++++ 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/src/Protocol/RequestDataHandler.php b/src/Protocol/RequestDataHandler.php index bf3ad03..3f2a281 100644 --- a/src/Protocol/RequestDataHandler.php +++ b/src/Protocol/RequestDataHandler.php @@ -1433,7 +1433,7 @@ public function validateLogoutToken( } if ($checkJtiReplay) { - $this->validateLogoutTokenJtiNotReplayed($logoutTokenJws); + $this->assertLogoutTokenJtiNotConsumed($logoutTokenJws); } return $logoutTokenJws; @@ -1441,21 +1441,19 @@ public function validateLogoutToken( /** * Reject logout tokens whose 'jti' value was already consumed (replay - * detection, per specification section 2.6, step 8). Consumed 'jti' - * values are recorded in the cache until the logout token itself can no - * longer validate (expiration plus slack). Cache errors disable the - * check (it is optional per specification) instead of rejecting logouts. + * detection, per specification section 2.6, step 8). A 'jti' is recorded + * as consumed only after the logout was actually performed (see + * registerLogoutTokenRevocation()), so this is a read-only check. Cache + * errors disable the check (it is optional per specification) instead of + * rejecting logouts. * * @throws OidcClientException If the logout token 'jti' was already * consumed. */ - protected function validateLogoutTokenJtiNotReplayed(LogoutToken $logoutTokenJws): void + protected function assertLogoutTokenJtiNotConsumed(LogoutToken $logoutTokenJws): void { - $cacheKey = self::CACHE_KEY_PREFIX_LOGOUT_TOKEN_JTI . - substr(hash('sha256', $logoutTokenJws->getIssuer() . "\n" . $logoutTokenJws->getJwtId()), 0, 56); - try { - $consumedAt = $this->cache->get($cacheKey); + $consumedAt = $this->cache->get($this->logoutTokenJtiCacheKey($logoutTokenJws)); } catch (Throwable $throwable) { $this->logger?->error( 'Could not check for logout token replay, skipping the check. ' . $throwable->getMessage(), @@ -1468,10 +1466,20 @@ protected function validateLogoutTokenJtiNotReplayed(LogoutToken $logoutTokenJws $this->logger?->error($error); throw new OidcClientException($error); } + } + /** + * Record the logout token's 'jti' as consumed (best-effort), so a later + * logout token with the same 'jti' is rejected as a replay. Called only + * after the logout was successfully performed, so a retry following a + * failed logout is not incorrectly rejected. Kept until the logout token + * itself can no longer validate (expiration plus slack). + */ + protected function markLogoutTokenJtiConsumed(LogoutToken $logoutTokenJws): void + { try { $this->cache->set( - $cacheKey, + $this->logoutTokenJtiCacheKey($logoutTokenJws), time(), max(60, $logoutTokenJws->getExpirationTime() + 300 - time()), ); @@ -1482,6 +1490,12 @@ protected function validateLogoutTokenJtiNotReplayed(LogoutToken $logoutTokenJws } } + protected function logoutTokenJtiCacheKey(LogoutToken $logoutTokenJws): string + { + return self::CACHE_KEY_PREFIX_LOGOUT_TOKEN_JTI . + substr(hash('sha256', $logoutTokenJws->getIssuer() . "\n" . $logoutTokenJws->getJwtId()), 0, 56); + } + /** * Record the login revocation requested by a (validated) logout token: * by OP issuer and 'sid' when present (that particular session), else by @@ -1489,6 +1503,11 @@ protected function validateLogoutTokenJtiNotReplayed(LogoutToken $logoutTokenJws * section 2.7. Affected persisted logins are then observed as terminated * (see getLoginData()). * + * On success the logout token's 'jti' is recorded as consumed (replay + * protection). This happens only after the revocation succeeds, so if the + * revocation fails the same token can still be retried (it is not left + * marked as a replay). + * * @throws OidcClientException If the revocation could not be recorded * (the back-channel logout endpoint should respond with HTTP 400, since * the logout was not performed). @@ -1499,18 +1518,18 @@ public function registerLogoutTokenRevocation(LogoutToken $logoutTokenJws): void if (is_string($sid = $logoutTokenJws->getSessionId())) { $this->loginRevocationRegistry->revokeSession($issuer, $sid); - return; - } - - if (is_string($sub = $logoutTokenJws->getSubject())) { + } elseif (is_string($sub = $logoutTokenJws->getSubject())) { $this->loginRevocationRegistry->revokeSubject($issuer, $sub); - return; + } else { + // Unreachable for validated logout tokens ('sub' and / or 'sid' is + // required), but do not silently acknowledge a logout which can + // not be correlated with any login. + throw new OidcClientException('Logout token does not identify a session or a subject.'); } - // Unreachable for validated logout tokens ('sub' and / or 'sid' is - // required), but do not silently acknowledge a logout which can not - // be correlated with any login. - throw new OidcClientException('Logout token does not identify a session or a subject.'); + // Only now that the logout has been performed, mark the token as + // consumed - a revocation failure above must not block a retry. + $this->markLogoutTokenJtiConsumed($logoutTokenJws); } protected function dateIntervalToSeconds(\DateInterval $dateInterval): int diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index 9edc877..a594325 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -2051,6 +2051,44 @@ public function testRegisterLogoutTokenRevocationThrowsWithoutSessionIdAndSubjec $this->sut()->registerLogoutTokenRevocation($logoutTokenJws); } + public function testRegisterLogoutTokenRevocationMarksJtiConsumedAfterSuccess(): void + { + $logoutTokenJws = $this->createMock(LogoutToken::class); + $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); + $logoutTokenJws->method('getSessionId')->willReturn('op-session-1'); + $logoutTokenJws->method('getJwtId')->willReturn('jti-1'); + $logoutTokenJws->method('getExpirationTime')->willReturn(time() + 120); + + $this->loginRevocationRegistryMock->expects($this->once())->method('revokeSession'); + + // The 'jti' is recorded as consumed only after the revocation succeeds. + $cacheMock = $this->createMock(CacheInterface::class); + $cacheMock->expects($this->once()) + ->method('set') + ->with($this->stringStartsWith('bcl_jti_')); + + $this->sut(cache: $cacheMock)->registerLogoutTokenRevocation($logoutTokenJws); + } + + public function testRegisterLogoutTokenRevocationDoesNotMarkJtiConsumedWhenRevocationFails(): void + { + $logoutTokenJws = $this->createMock(LogoutToken::class); + $logoutTokenJws->method('getIssuer')->willReturn('https://op.example.org'); + $logoutTokenJws->method('getSessionId')->willReturn('op-session-1'); + + $this->loginRevocationRegistryMock->method('revokeSession') + ->willThrowException(new OidcClientException('Could not record login revocation.')); + + // A failed revocation must NOT consume the 'jti', so the OP can retry + // the same logout token. + $cacheMock = $this->createMock(CacheInterface::class); + $cacheMock->expects($this->never())->method('set'); + + $this->expectException(OidcClientException::class); + + $this->sut(cache: $cacheMock)->registerLogoutTokenRevocation($logoutTokenJws); + } + public function testGetLoginDataClearsRevokedSessionLogin(): void { $loggedInAt = time() - 60; From dbd67d62bdc23d5721170ca0e557be01b20f5374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 6 Jul 2026 09:58:29 +0200 Subject: [PATCH 06/10] Validate aud against persisted clients --- docs/4-Dynamically-Registered-Client.md | 13 +- src/DynamicallyRegisteredClient.php | 80 +++++++++++- src/Protocol/RequestDataHandler.php | 32 +++++ .../Oidc/DynamicallyRegisteredClientTest.php | 114 +++++++++++++++++- .../Oidc/Protocol/RequestDataHandlerTest.php | 42 +++++++ 5 files changed, 267 insertions(+), 14 deletions(-) diff --git a/docs/4-Dynamically-Registered-Client.md b/docs/4-Dynamically-Registered-Client.md index 605caff..4cb510c 100644 --- a/docs/4-Dynamically-Registered-Client.md +++ b/docs/4-Dynamically-Registered-Client.md @@ -234,11 +234,14 @@ $oidcClient = new DynamicallyRegisteredClient( $oidcClient->handleBackchannelLogoutRequest(); ``` -The logout token audience is validated against the client ID of the -persisted client registration - no client registration is performed or -updated while handling back-channel logout requests. Note that providing -`backchannelLogoutUri` changes the client metadata set, so an existing -client registration will be updated (or replaced) accordingly. +The logout token audience is validated against the persisted client +registrations - the current one and any retained per-client entry of a +replaced registration - so a logout for a superseded (but still persisted) +registration is still honored while old-client sessions may exist. No +client registration is performed or updated while handling back-channel +logout requests. Note that providing `backchannelLogoutUri` changes the +client metadata set, so an existing client registration will be updated (or +replaced) accordingly. ## Requirements on the OpenID Provider diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php index bb680de..aa85486 100644 --- a/src/DynamicallyRegisteredClient.php +++ b/src/DynamicallyRegisteredClient.php @@ -793,10 +793,13 @@ public function validateLogoutCallback(?ServerRequestInterface $request = null): * 'backchannel_logout_uri' client metadata (see the $backchannelLogoutUri * constructor parameter). * - * The logout token audience is validated against the client ID of the - * persisted client registration - no client registration is performed - * or updated here. Without a persisted registration the logout token - * can not be validated, so the request is rejected. + * The logout token audience is validated against the persisted client + * registrations - the current one and any retained per-client entry of a + * replaced registration - so a logout for a superseded (but still + * persisted) registration is still honored while old-client sessions may + * exist. No client registration is performed or updated here. When the + * audience matches no persisted registration, the logout token can not be + * validated and the request is rejected. * * For notes on how the revocation terminates the affected login, refer * to PreRegisteredClient::handleBackchannelLogoutRequest(). @@ -816,11 +819,20 @@ public function handleBackchannelLogoutRequest( throw new OidcClientException('JWKS URI not found in OP metadata.'); } - $clientId = $this->loadRegistrationData()?->getClientId(); + // Determine which persisted client registration the logout token + // is addressed to (by audience / authorized party), so a logout + // for a replaced registration whose per-client entry is still + // persisted is honored - not only the current registration. + $audienceInfo = $requestDataHandler->parseBackchannelLogoutTokenAudienceInfo($logoutToken); + $clientId = $this->resolveRegisteredClientIdForLogout( + $audienceInfo['audiences'], + $audienceInfo['authorizedParty'], + ); if ($clientId === null) { throw new OidcClientException( - 'No persisted client registration found, so the logout token audience can not be validated.', + 'Logout token audience does not match any persisted client registration, ' . + 'so it can not be validated.', ); } @@ -848,6 +860,62 @@ public function handleBackchannelLogoutRequest( return HttpHelper::dispatchBackchannelLogoutResponse($response, null, $this->logger); } + /** + * Resolve which of this client's persisted registrations a back-channel + * logout token is addressed to, by matching the token audience(s) against + * the current registration and any retained per-client registration entry + * (of a replaced registration). Returns the matching client ID, or null + * when nothing matches a persisted registration. No client registration + * is performed or updated here (registration entries are only read). + * + * When the token has multiple audiences, its authorized party ('azp') + * identifies the party it is intended for, so it is preferred - this + * disambiguates the case where several audiences are persisted client + * IDs (and matches the 'azp' check performed during validation). + * + * @param mixed[] $audiences Logout token audience value(s). + * @param ?string $authorizedParty Logout token 'azp' claim value. + */ + protected function resolveRegisteredClientIdForLogout(array $audiences, ?string $authorizedParty): ?string + { + if (is_string($authorizedParty) && $authorizedParty !== '') { + return $this->hasPersistedClientRegistration($authorizedParty) ? $authorizedParty : null; + } + + foreach ($audiences as $audience) { + if (is_string($audience) && $audience !== '' && $this->hasPersistedClientRegistration($audience)) { + return $audience; + } + } + + return null; + } + + /** + * Whether a client registration for the given client ID is persisted - + * either the current registration or a retained per-client entry of a + * replaced registration. Registration entries are only read; no client + * registration is performed or updated. + */ + protected function hasPersistedClientRegistration(string $clientId): bool + { + if ($clientId === $this->loadRegistrationData()?->getClientId()) { + return true; + } + + try { + return $this->registrationStore->get($this->getClientRegistrationStoreKey($clientId)) !== null; + } catch (Throwable $throwable) { + $this->logger?->warning( + 'Error reading persisted client registration while resolving a back-channel ' . + 'logout token audience. ' . $throwable->getMessage(), + ['clientId' => $clientId], + ); + + return false; + } + } + /** * Raw ID token received at the last successful login, or null when not * available. Read from the session store - no client registration is diff --git a/src/Protocol/RequestDataHandler.php b/src/Protocol/RequestDataHandler.php index 3f2a281..ecc5640 100644 --- a/src/Protocol/RequestDataHandler.php +++ b/src/Protocol/RequestDataHandler.php @@ -1259,6 +1259,38 @@ public function parseBackchannelLogoutRequest(?ServerRequestInterface $request = return $logoutToken; } + /** + * Parse an OIDC Back-Channel Logout token WITHOUT verifying its + * signature, returning the claims used to determine which client the + * token is addressed to before selecting the client registration to + * validate it against (e.g. for dynamically registered clients whose + * registration may have been replaced): the audience(s) ('aud') and the + * authorized party ('azp'). When a token has multiple audiences, 'azp' + * identifies the party it is intended for. The token is still fully + * validated, including its signature, separately via validateLogoutToken(). + * + * @return array{audiences: mixed[], authorizedParty: ?non-empty-string} + * @throws OidcClientException If the logout token can not be parsed. + */ + public function parseBackchannelLogoutTokenAudienceInfo(string $logoutToken): array + { + try { + $logoutTokenJws = $this->core->logoutTokenFactory()->fromToken($logoutToken); + $authorizedParty = $logoutTokenJws->getPayloadClaim(ClaimsEnum::Azp->value); + + return [ + 'audiences' => $logoutTokenJws->getAudience(), + 'authorizedParty' => (is_string($authorizedParty) && $authorizedParty !== '') ? + $authorizedParty : + null, + ]; + } catch (Throwable $throwable) { + $error = 'Error parsing Logout Token audience. ' . $throwable->getMessage(); + $this->logger?->error($error); + throw new OidcClientException($error, (int) $throwable->getCode(), $throwable); + } + } + /** * Validate a logout token per OIDC Back-Channel Logout 1.0, section 2.6. * diff --git a/tests/Oidc/DynamicallyRegisteredClientTest.php b/tests/Oidc/DynamicallyRegisteredClientTest.php index d87e5c1..38d5c68 100644 --- a/tests/Oidc/DynamicallyRegisteredClientTest.php +++ b/tests/Oidc/DynamicallyRegisteredClientTest.php @@ -1053,6 +1053,11 @@ public function testHandleBackchannelLogoutRequestSuccess(): void ->with($request) ->willReturn('logout-token'); + // The logout token is addressed to the current registration. + $this->requestDataHandlerMock->method('parseBackchannelLogoutTokenAudienceInfo') + ->with('logout-token') + ->willReturn(['audiences' => ['registered-client-id'], 'authorizedParty' => null]); + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], @@ -1091,16 +1096,18 @@ public function testHandleBackchannelLogoutRequestSuccess(): void $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest($request, $response)); } - public function testHandleBackchannelLogoutRequestRespondsWith400WithoutRegistration(): void + public function testHandleBackchannelLogoutRequestRespondsWith400WhenAudienceMatchesNoRegistration(): void { $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + $this->requestDataHandlerMock->method('parseBackchannelLogoutTokenAudienceInfo') + ->willReturn(['audiences' => ['unknown-client-id'], 'authorizedParty' => null]); $this->metadataMock->expects($this->once())->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], ]); - // No persisted client registration. + // No persisted client registration matches the audience. $this->registrationStoreMock->method('get')->willReturn(null); $this->requestDataHandlerMock->expects($this->never())->method('validateLogoutToken'); @@ -1108,7 +1115,7 @@ public function testHandleBackchannelLogoutRequestRespondsWith400WithoutRegistra $body = $this->createMock(\Psr\Http\Message\StreamInterface::class); $body->expects($this->once()) ->method('write') - ->with($this->stringContains('No persisted client registration found')); + ->with($this->stringContains('does not match any persisted client registration')); $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); $response->expects($this->once())->method('withStatus')->with(400)->willReturn($response); @@ -1121,6 +1128,8 @@ public function testHandleBackchannelLogoutRequestRespondsWith400WithoutRegistra public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogoutToken(): void { $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + $this->requestDataHandlerMock->method('parseBackchannelLogoutTokenAudienceInfo') + ->willReturn(['audiences' => ['registered-client-id'], 'authorizedParty' => null]); $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], @@ -1149,4 +1158,103 @@ public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogout $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); } + + public function testHandleBackchannelLogoutRequestHonorsReplacedClientRegistration(): void + { + // The current registration has a new client ID, but a logout token + // arrives for the old (replaced) client ID whose per-client entry is + // still persisted - it must be honored, not rejected. + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + $this->requestDataHandlerMock->method('parseBackchannelLogoutTokenAudienceInfo') + ->willReturn(['audiences' => ['old-client-id'], 'authorizedParty' => null]); + + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ + ['jwks_uri', 'https://op.example.org/jwks'], + ['issuer', 'https://op.example.org'], + ]); + + $mainKey = $this->sut()->getRegistrationStoreKey(); + $oldClientKey = $this->sut()->getClientRegistrationStoreKey('old-client-id'); + + $this->registrationStoreMock->expects($this->exactly(2))->method('get')->willReturnMap([ + // Current registration (new client ID). + [$mainKey, ['client_id' => 'new-client-id', 'client_secret' => 'new-secret']], + // Retained per-client entry of the replaced registration. + [$oldClientKey, ['client_id' => 'old-client-id', 'client_secret' => 'old-secret']], + ]); + + $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + + // The logout token is validated against the OLD (replaced) client ID. + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutToken') + ->with( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + 'old-client-id', + 'RS256', + ) + ->willReturn($logoutTokenJws); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('registerLogoutTokenRevocation') + ->with($logoutTokenJws); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(200)->willReturn($response); + $response->method('withHeader')->willReturn($response); + + $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); + } + + public function testHandleBackchannelLogoutRequestUsesAuthorizedPartyToDisambiguateMultipleAudiences(): void + { + // The logout token lists two audiences that are both persisted client + // IDs; the 'azp' claim must decide which one it is for. + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + $this->requestDataHandlerMock->method('parseBackchannelLogoutTokenAudienceInfo') + ->willReturn([ + 'audiences' => ['client-a', 'client-b'], + 'authorizedParty' => 'client-b', + ]); + + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ + ['jwks_uri', 'https://op.example.org/jwks'], + ['issuer', 'https://op.example.org'], + ]); + + $mainKey = $this->sut()->getRegistrationStoreKey(); + $clientBKey = $this->sut()->getClientRegistrationStoreKey('client-b'); + + $this->registrationStoreMock->expects($this->exactly(2))->method('get')->willReturnMap([ + // Current registration is 'client-a' (also a token audience). + [$mainKey, ['client_id' => 'client-a', 'client_secret' => 'secret-a']], + // 'client-b' is a retained per-client entry. + [$clientBKey, ['client_id' => 'client-b', 'client_secret' => 'secret-b']], + ]); + + $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + + // Despite 'client-a' being the current registration and first audience, + // 'azp' selects 'client-b'. + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutToken') + ->with( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + 'client-b', + 'RS256', + ) + ->willReturn($logoutTokenJws); + + $this->requestDataHandlerMock->expects($this->once())->method('registerLogoutTokenRevocation'); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(200)->willReturn($response); + $response->method('withHeader')->willReturn($response); + + $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); + } } diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index a594325..a84b461 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -1708,6 +1708,48 @@ public function testParseBackchannelLogoutRequestThrowsOnMissingLogoutToken(): v $this->sut()->parseBackchannelLogoutRequest($request); } + public function testParseBackchannelLogoutTokenAudienceInfoReturnsAudiencesAndAuthorizedParty(): void + { + $logoutTokenFactory = $this->createMock(LogoutTokenFactory::class); + $this->coreMock->method('logoutTokenFactory')->willReturn($logoutTokenFactory); + $logoutTokenJws = $this->createMock(LogoutToken::class); + $logoutTokenFactory->method('fromToken')->with('logout-token')->willReturn($logoutTokenJws); + $logoutTokenJws->method('getAudience')->willReturn(['client-a', 'client-b']); + $logoutTokenJws->method('getPayloadClaim')->with('azp')->willReturn('client-b'); + + $this->assertSame( + ['audiences' => ['client-a', 'client-b'], 'authorizedParty' => 'client-b'], + $this->sut()->parseBackchannelLogoutTokenAudienceInfo('logout-token'), + ); + } + + public function testParseBackchannelLogoutTokenAudienceInfoReturnsNullAuthorizedPartyWhenAbsent(): void + { + $logoutTokenFactory = $this->createMock(LogoutTokenFactory::class); + $this->coreMock->method('logoutTokenFactory')->willReturn($logoutTokenFactory); + $logoutTokenJws = $this->createMock(LogoutToken::class); + $logoutTokenFactory->method('fromToken')->willReturn($logoutTokenJws); + $logoutTokenJws->method('getAudience')->willReturn(['client-a']); + $logoutTokenJws->method('getPayloadClaim')->with('azp')->willReturn(null); + + $this->assertSame( + ['audiences' => ['client-a'], 'authorizedParty' => null], + $this->sut()->parseBackchannelLogoutTokenAudienceInfo('logout-token'), + ); + } + + public function testParseBackchannelLogoutTokenAudienceInfoThrowsOnParseError(): void + { + $logoutTokenFactory = $this->createMock(LogoutTokenFactory::class); + $this->coreMock->method('logoutTokenFactory')->willReturn($logoutTokenFactory); + $logoutTokenFactory->method('fromToken')->willThrowException(new JwsException('Invalid token')); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('Error parsing Logout Token audience. Invalid token'); + + $this->sut()->parseBackchannelLogoutTokenAudienceInfo('logout-token'); + } + /** * Set up JWKS and logout token factory mocks, returning the LogoutToken * mock which the factory produces. From 0e275478c234c28177140755d6ebb4269d285ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 6 Jul 2026 10:21:02 +0200 Subject: [PATCH 07/10] Validate against required sid --- docs/4-Dynamically-Registered-Client.md | 8 ++- src/DynamicallyRegisteredClient.php | 1 + src/Protocol/RequestDataHandler.php | 19 +++++++ .../Oidc/DynamicallyRegisteredClientTest.php | 42 +++++++++++++++ .../Oidc/Protocol/RequestDataHandlerTest.php | 52 +++++++++++++++++++ 5 files changed, 121 insertions(+), 1 deletion(-) diff --git a/docs/4-Dynamically-Registered-Client.md b/docs/4-Dynamically-Registered-Client.md index 4cb510c..46a7c9f 100644 --- a/docs/4-Dynamically-Registered-Client.md +++ b/docs/4-Dynamically-Registered-Client.md @@ -239,7 +239,13 @@ registrations - the current one and any retained per-client entry of a replaced registration - so a logout for a superseded (but still persisted) registration is still honored while old-client sessions may exist. No client registration is performed or updated while handling back-channel -logout requests. Note that providing `backchannelLogoutUri` changes the +logout requests. + +When the client is registered with `backchannelLogoutSessionRequired: true`, +a logout token that does not carry a `sid` claim is rejected (responded to +with HTTP 400), since such a client asked the OP to always identify the exact +session to terminate rather than falling back to a subject-wide logout. +Note that providing `backchannelLogoutUri` changes the client metadata set, so an existing client registration will be updated (or replaced) accordingly. diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php index aa85486..c268831 100644 --- a/src/DynamicallyRegisteredClient.php +++ b/src/DynamicallyRegisteredClient.php @@ -842,6 +842,7 @@ public function handleBackchannelLogoutRequest( expectedIssuer: MetadataHelper::optionalString($this->metadata, ClaimsEnum::Issuer->value), expectedClientId: $clientId, expectedSigningAlgorithm: $this->idTokenSignedResponseAlg, + requireSid: $this->backchannelLogoutSessionRequired === true, ); $requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); diff --git a/src/Protocol/RequestDataHandler.php b/src/Protocol/RequestDataHandler.php index ecc5640..7207fef 100644 --- a/src/Protocol/RequestDataHandler.php +++ b/src/Protocol/RequestDataHandler.php @@ -1315,6 +1315,11 @@ public function parseBackchannelLogoutTokenAudienceInfo(string $logoutToken): ar * algorithms" as ID Token validation, per specification section 2.6, so * a logout token that is validly signed but with a different algorithm * than expected is rejected. + * @param bool $requireSid When true, a logout token without a 'sid' claim + * is rejected. Set this when the RP registered + * 'backchannel_logout_session_required' as true, since then the OP is + * expected to include a 'sid' identifying the specific session to + * terminate (specification sections 2.4 and 2.6). * @param ?\DateInterval $logoutTokenMaxAge Maximum accepted logout token * age ('iat' claim freshness), null to disable the check. Default is 5 * minutes. @@ -1330,6 +1335,7 @@ public function validateLogoutToken( ?string $expectedIssuer = null, ?string $expectedClientId = null, ?string $expectedSigningAlgorithm = null, + bool $requireSid = false, ?\DateInterval $logoutTokenMaxAge = new \DateInterval(self::DEFAULT_LOGOUT_TOKEN_MAX_AGE), bool $checkJtiReplay = true, bool $refreshCache = false, @@ -1390,6 +1396,7 @@ public function validateLogoutToken( expectedIssuer: $expectedIssuer, expectedClientId: $expectedClientId, expectedSigningAlgorithm: $expectedSigningAlgorithm, + requireSid: $requireSid, logoutTokenMaxAge: $logoutTokenMaxAge, checkJtiReplay: $checkJtiReplay, refreshCache: true, @@ -1439,6 +1446,18 @@ public function validateLogoutToken( } } + // When the RP registered 'backchannel_logout_session_required' as + // true, the OP is expected to include a 'sid' identifying the exact + // session to terminate (specification sections 2.4 and 2.6). A logout + // token without a 'sid' would otherwise fall back to a subject-wide + // revocation, which is broader than what such an RP asked for, so it + // is rejected here. + if ($requireSid && !is_string($logoutTokenJws->getSessionId())) { + $error = 'Logout token does not contain a "sid" claim, which is required for this client.'; + $this->logger?->error($error); + throw new OidcClientException($error); + } + // Explicit typing ('typ' header of 'logout+jwt') is only RECOMMENDED // by the specification, and requiring it would break existing OP // deployments - so an unexpected value is only logged. diff --git a/tests/Oidc/DynamicallyRegisteredClientTest.php b/tests/Oidc/DynamicallyRegisteredClientTest.php index 38d5c68..4dddf2d 100644 --- a/tests/Oidc/DynamicallyRegisteredClientTest.php +++ b/tests/Oidc/DynamicallyRegisteredClientTest.php @@ -1257,4 +1257,46 @@ public function testHandleBackchannelLogoutRequestUsesAuthorizedPartyToDisambigu $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); } + + public function testHandleBackchannelLogoutRequestRequiresSidWhenSessionRequired(): void + { + // A client registered with 'backchannel_logout_session_required' true + // must have logout tokens validated with the 'sid' requirement on. + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + $this->requestDataHandlerMock->method('parseBackchannelLogoutTokenAudienceInfo') + ->willReturn(['audiences' => ['registered-client-id'], 'authorizedParty' => null]); + + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ + ['jwks_uri', 'https://op.example.org/jwks'], + ['issuer', 'https://op.example.org'], + ]); + + $this->registrationStoreMock->method('get')->willReturn([ + 'client_id' => 'registered-client-id', + 'client_secret' => 'client-secret', + ]); + + $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutToken') + ->with( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + 'registered-client-id', + 'RS256', + true, + ) + ->willReturn($logoutTokenJws); + + $this->requestDataHandlerMock->expects($this->once())->method('registerLogoutTokenRevocation'); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(200)->willReturn($response); + $response->method('withHeader')->willReturn($response); + + $sut = $this->sut(backchannelLogoutSessionRequired: true); + $this->assertSame($response, $sut->handleBackchannelLogoutRequest(null, $response)); + } } diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index a84b461..fc4f823 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -1952,6 +1952,58 @@ public function testValidateLogoutTokenAcceptsExpectedSigningAlgorithm(): void $this->assertSame($logoutTokenJws, $result); } + public function testValidateLogoutTokenThrowsOnMissingSidWhenRequired(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $logoutTokenJws->method('getAlgorithm')->willReturn('RS256'); + $logoutTokenJws->expects($this->once())->method('verifyWithKeySet'); + // A subject-only logout token (no 'sid') is rejected when 'sid' is required. + $logoutTokenJws->method('getSessionId')->willReturn(null); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('does not contain a "sid" claim'); + + $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + requireSid: true, + ); + } + + public function testValidateLogoutTokenAcceptsPresentSidWhenRequired(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $this->configureValidLogoutToken($logoutTokenJws); + $logoutTokenJws->expects($this->once())->method('verifyWithKeySet'); + + $result = $this->sut()->validateLogoutToken( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + 'client-id', + requireSid: true, + ); + + $this->assertSame($logoutTokenJws, $result); + } + + public function testValidateLogoutTokenAllowsMissingSidWhenNotRequired(): void + { + $logoutTokenJws = $this->mockLogoutTokenSetup(); + $logoutTokenJws->method('getAlgorithm')->willReturn('RS256'); + $logoutTokenJws->method('getType')->willReturn('logout+jwt'); + $logoutTokenJws->method('getIssuedAt')->willReturn(time()); + $logoutTokenJws->method('getExpirationTime')->willReturn(time() + 120); + $logoutTokenJws->method('getJwtId')->willReturn('jti-1'); + $logoutTokenJws->method('getSessionId')->willReturn(null); + $logoutTokenJws->expects($this->once())->method('verifyWithKeySet'); + + // A subject-only logout token is accepted when 'sid' is not required. + $result = $this->sut()->validateLogoutToken('logout-token', 'https://op.example.org/jwks'); + + $this->assertSame($logoutTokenJws, $result); + } + public function testValidateLogoutTokenWarnsOnUnexpectedTypHeader(): void { $logoutTokenJws = $this->mockLogoutTokenSetup(); From 9b617532386abf6e6499d70a2259955c733ffd0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 6 Jul 2026 10:39:24 +0200 Subject: [PATCH 08/10] Harden sid requirement --- docs/4-Dynamically-Registered-Client.md | 11 +++-- src/DynamicallyRegisteredClient.php | 19 ++++++- .../Oidc/DynamicallyRegisteredClientTest.php | 49 ++++++++++++++++++- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/docs/4-Dynamically-Registered-Client.md b/docs/4-Dynamically-Registered-Client.md index 46a7c9f..8c319a6 100644 --- a/docs/4-Dynamically-Registered-Client.md +++ b/docs/4-Dynamically-Registered-Client.md @@ -241,10 +241,13 @@ registration is still honored while old-client sessions may exist. No client registration is performed or updated while handling back-channel logout requests. -When the client is registered with `backchannelLogoutSessionRequired: true`, -a logout token that does not carry a `sid` claim is rejected (responded to -with HTTP 400), since such a client asked the OP to always identify the exact -session to terminate rather than falling back to a subject-wide logout. +When the client's effective registration metadata declares +`backchannel_logout_session_required` as true - whether through the +`backchannelLogoutSessionRequired` constructor parameter or an +`additionalClientMetadata` override - a logout token that does not carry a +`sid` claim is rejected (responded to with HTTP 400), since such a client +asked the OP to always identify the exact session to terminate rather than +falling back to a subject-wide logout. Note that providing `backchannelLogoutUri` changes the client metadata set, so an existing client registration will be updated (or replaced) accordingly. diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php index c268831..2efd819 100644 --- a/src/DynamicallyRegisteredClient.php +++ b/src/DynamicallyRegisteredClient.php @@ -842,7 +842,7 @@ public function handleBackchannelLogoutRequest( expectedIssuer: MetadataHelper::optionalString($this->metadata, ClaimsEnum::Issuer->value), expectedClientId: $clientId, expectedSigningAlgorithm: $this->idTokenSignedResponseAlg, - requireSid: $this->backchannelLogoutSessionRequired === true, + requireSid: $this->isBackchannelLogoutSessionRequired(), ); $requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); @@ -861,6 +861,23 @@ public function handleBackchannelLogoutRequest( return HttpHelper::dispatchBackchannelLogoutResponse($response, null, $this->logger); } + /** + * Whether this client's effective registration metadata declares + * 'backchannel_logout_session_required' as true. This is derived from the + * metadata actually registered on the OP (see + * buildClientRegistrationMetadata()), so it accounts for both the dedicated + * 'backchannelLogoutSessionRequired' constructor parameter and any + * 'additionalClientMetadata' override of that claim - the two are otherwise + * merged, and the override wins. When true, logout tokens without a 'sid' + * claim are rejected (a subject-wide fallback would be broader than what the + * OP was told this client requires). + */ + protected function isBackchannelLogoutSessionRequired(): bool + { + return ($this->buildClientRegistrationMetadata()[ClaimsEnum::BackChannelLogoutSessionRequired->value] ?? null) + === true; + } + /** * Resolve which of this client's persisted registrations a back-channel * logout token is addressed to, by matching the token audience(s) against diff --git a/tests/Oidc/DynamicallyRegisteredClientTest.php b/tests/Oidc/DynamicallyRegisteredClientTest.php index 4dddf2d..a580737 100644 --- a/tests/Oidc/DynamicallyRegisteredClientTest.php +++ b/tests/Oidc/DynamicallyRegisteredClientTest.php @@ -1296,7 +1296,54 @@ public function testHandleBackchannelLogoutRequestRequiresSidWhenSessionRequired $response->expects($this->once())->method('withStatus')->with(200)->willReturn($response); $response->method('withHeader')->willReturn($response); - $sut = $this->sut(backchannelLogoutSessionRequired: true); + $sut = $this->sut( + backchannelLogoutUri: 'https://rp.example.org/backchannel-logout', + backchannelLogoutSessionRequired: true, + ); + $this->assertSame($response, $sut->handleBackchannelLogoutRequest(null, $response)); + } + + public function testHandleBackchannelLogoutRequestRequiresSidWhenSessionRequiredViaAdditionalMetadata(): void + { + // 'backchannel_logout_session_required' can also be registered through + // additionalClientMetadata (which overrides the constructor option); + // the 'sid' requirement must be derived from the effective metadata. + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + $this->requestDataHandlerMock->method('parseBackchannelLogoutTokenAudienceInfo') + ->willReturn(['audiences' => ['registered-client-id'], 'authorizedParty' => null]); + + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ + ['jwks_uri', 'https://op.example.org/jwks'], + ['issuer', 'https://op.example.org'], + ]); + + $this->registrationStoreMock->method('get')->willReturn([ + 'client_id' => 'registered-client-id', + 'client_secret' => 'client-secret', + ]); + + $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutToken') + ->with( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + 'registered-client-id', + 'RS256', + true, + ) + ->willReturn($logoutTokenJws); + + $this->requestDataHandlerMock->expects($this->once())->method('registerLogoutTokenRevocation'); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(200)->willReturn($response); + $response->method('withHeader')->willReturn($response); + + // No dedicated constructor option - only the additionalClientMetadata override. + $sut = $this->sut(additionalClientMetadata: ['backchannel_logout_session_required' => true]); $this->assertSame($response, $sut->handleBackchannelLogoutRequest(null, $response)); } } From 7721d319ad24e72f4d8c03e30a11fdecd39da4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 6 Jul 2026 16:54:40 +0200 Subject: [PATCH 09/10] Handle sid requirement for pre-registered client --- docs/2-Pre-Registered-Client.md | 7 ++ docs/3-Federated-Client.md | 5 + docs/4-Dynamically-Registered-Client.md | 31 +++--- src/DynamicallyRegisteredClient.php | 101 ++++++++++++------ src/FederatedClient.php | 14 +++ src/PreRegisteredClient.php | 7 ++ .../Oidc/DynamicallyRegisteredClientTest.php | 52 +++++---- tests/Oidc/FederatedClientTest.php | 53 +++++++++ tests/Oidc/PreRegisteredClientTest.php | 37 +++++++ 9 files changed, 238 insertions(+), 69 deletions(-) diff --git a/docs/2-Pre-Registered-Client.md b/docs/2-Pre-Registered-Client.md index 32d4946..f2b475c 100644 --- a/docs/2-Pre-Registered-Client.md +++ b/docs/2-Pre-Registered-Client.md @@ -319,6 +319,13 @@ which defaults to `RS256` per OpenID Connect. If your OP signs tokens with a different algorithm, set the `idTokenSignedResponseAlg` constructor parameter accordingly (or to `null` to accept any supported algorithm). +If this client was registered on the OP with +`backchannel_logout_session_required` set to true (meaning the OP always +includes a `sid` identifying the exact session to terminate), set the +`backchannelLogoutSessionRequired` constructor parameter to true. Logout +tokens that then arrive without a `sid` claim are rejected (HTTP 400), +instead of falling back to a broader subject-wide logout. + ### How the affected login is terminated A back-channel logout request arrives outside the context of the End-User's diff --git a/docs/3-Federated-Client.md b/docs/3-Federated-Client.md index cfdb903..bad8945 100644 --- a/docs/3-Federated-Client.md +++ b/docs/3-Federated-Client.md @@ -203,6 +203,11 @@ be resolved from it to one of the configured Trust Anchors. The OP JWKS used to verify the logout token signature is taken from the resolved OP metadata. +If this RP publishes `backchannel_logout_session_required` as true in its +Relying Party metadata (via the Relying Party configuration additional +claims), logout tokens that arrive without a `sid` claim are rejected +(HTTP 400) instead of falling back to a broader subject-wide logout. + ## 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 8c319a6..f86a51e 100644 --- a/docs/4-Dynamically-Registered-Client.md +++ b/docs/4-Dynamically-Registered-Client.md @@ -237,20 +237,23 @@ $oidcClient->handleBackchannelLogoutRequest(); The logout token audience is validated against the persisted client registrations - the current one and any retained per-client entry of a replaced registration - so a logout for a superseded (but still persisted) -registration is still honored while old-client sessions may exist. No -client registration is performed or updated while handling back-channel -logout requests. - -When the client's effective registration metadata declares -`backchannel_logout_session_required` as true - whether through the -`backchannelLogoutSessionRequired` constructor parameter or an -`additionalClientMetadata` override - a logout token that does not carry a -`sid` claim is rejected (responded to with HTTP 400), since such a client -asked the OP to always identify the exact session to terminate rather than -falling back to a subject-wide logout. -Note that providing `backchannelLogoutUri` changes the -client metadata set, so an existing client registration will be updated (or -replaced) accordingly. +registration is still honored while old-client sessions may exist. The +logout token is validated using the policy of the matched registration +itself - the signing algorithm (`id_token_signed_response_alg`) and the +`sid` requirement (`backchannel_logout_session_required`) it was registered +with - so a registration that was replaced after those settings changed +still validates its own logout tokens correctly. No client registration is +performed or updated while handling back-channel logout requests. + +When the matched registration declares `backchannel_logout_session_required` +as true, a logout token that does not carry a `sid` claim is rejected +(responded to with HTTP 400), since such a client asked the OP to always +identify the exact session to terminate rather than falling back to a +subject-wide logout. This is registered through the +`backchannelLogoutSessionRequired` constructor parameter (or an +`additionalClientMetadata` override). Note that providing `backchannelLogoutUri` +changes the client metadata set, so an existing client registration will be +updated (or replaced) accordingly. ## Requirements on the OpenID Provider diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php index 2efd819..28abcf6 100644 --- a/src/DynamicallyRegisteredClient.php +++ b/src/DynamicallyRegisteredClient.php @@ -824,25 +824,31 @@ public function handleBackchannelLogoutRequest( // for a replaced registration whose per-client entry is still // persisted is honored - not only the current registration. $audienceInfo = $requestDataHandler->parseBackchannelLogoutTokenAudienceInfo($logoutToken); - $clientId = $this->resolveRegisteredClientIdForLogout( + $registrationData = $this->resolveRegistrationForLogout( $audienceInfo['audiences'], $audienceInfo['authorizedParty'], ); - if ($clientId === null) { + if (!$registrationData instanceof \Cicnavi\Oidc\Registration\ClientRegistrationData) { throw new OidcClientException( 'Logout token audience does not match any persisted client registration, ' . 'so it can not be validated.', ); } + // Validate against the policy of the matched registration itself, + // not the current one: a registration that was replaced (e.g. after + // changing 'id_token_signed_response_alg' or + // 'backchannel_logout_session_required') keeps the signing algorithm + // and 'sid' requirement it was registered with, so logout tokens for + // sessions under the old client are validated correctly. $logoutTokenJws = $requestDataHandler->validateLogoutToken( logoutToken: $logoutToken, jwksUri: $opJwksUri, expectedIssuer: MetadataHelper::optionalString($this->metadata, ClaimsEnum::Issuer->value), - expectedClientId: $clientId, - expectedSigningAlgorithm: $this->idTokenSignedResponseAlg, - requireSid: $this->isBackchannelLogoutSessionRequired(), + expectedClientId: $registrationData->getClientId(), + expectedSigningAlgorithm: $this->registeredIdTokenSignedResponseAlg($registrationData), + requireSid: $this->registeredBackchannelLogoutSessionRequired($registrationData), ); $requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); @@ -862,29 +868,42 @@ public function handleBackchannelLogoutRequest( } /** - * Whether this client's effective registration metadata declares - * 'backchannel_logout_session_required' as true. This is derived from the - * metadata actually registered on the OP (see - * buildClientRegistrationMetadata()), so it accounts for both the dedicated - * 'backchannelLogoutSessionRequired' constructor parameter and any - * 'additionalClientMetadata' override of that claim - the two are otherwise - * merged, and the override wins. When true, logout tokens without a 'sid' - * claim are rejected (a subject-wide fallback would be broader than what the - * OP was told this client requires). + * The signing algorithm the OP uses for the given registration's ID and + * logout tokens, read from that registration's own persisted metadata + * ('id_token_signed_response_alg'). Falls back to this client's configured + * 'idTokenSignedResponseAlg' when the registration does not carry the claim + * (e.g. it was not registered explicitly), so validation still applies the + * expected default (RS256). */ - protected function isBackchannelLogoutSessionRequired(): bool + protected function registeredIdTokenSignedResponseAlg(ClientRegistrationData $registrationData): ?string { - return ($this->buildClientRegistrationMetadata()[ClaimsEnum::BackChannelLogoutSessionRequired->value] ?? null) - === true; + $alg = $registrationData->getClaims()[ClaimsEnum::IdTokenSignedResponseAlg->value] ?? null; + + return (is_string($alg) && $alg !== '') ? $alg : $this->idTokenSignedResponseAlg; + } + + /** + * Whether the given registration declares 'backchannel_logout_session_required' + * as true, read from that registration's own persisted metadata. When true, + * logout tokens without a 'sid' claim are rejected (a subject-wide fallback + * would be broader than what that registration required). Derived per + * registration so a replaced registration keeps the 'sid' policy it was + * registered with, independent of the current configuration. + */ + protected function registeredBackchannelLogoutSessionRequired(ClientRegistrationData $registrationData): bool + { + return ($registrationData->getClaims()[ClaimsEnum::BackChannelLogoutSessionRequired->value] ?? null) === true; } /** * Resolve which of this client's persisted registrations a back-channel * logout token is addressed to, by matching the token audience(s) against * the current registration and any retained per-client registration entry - * (of a replaced registration). Returns the matching client ID, or null - * when nothing matches a persisted registration. No client registration - * is performed or updated here (registration entries are only read). + * (of a replaced registration). Returns the matching registration snapshot + * (so its own signing algorithm / 'sid' policy can be used for validation), + * or null when nothing matches a persisted registration. No client + * registration is performed or updated here (registration entries are only + * read). * * When the token has multiple audiences, its authorized party ('azp') * identifies the party it is intended for, so it is preferred - this @@ -894,15 +913,22 @@ protected function isBackchannelLogoutSessionRequired(): bool * @param mixed[] $audiences Logout token audience value(s). * @param ?string $authorizedParty Logout token 'azp' claim value. */ - protected function resolveRegisteredClientIdForLogout(array $audiences, ?string $authorizedParty): ?string - { + protected function resolveRegistrationForLogout( + array $audiences, + ?string $authorizedParty, + ): ?ClientRegistrationData { if (is_string($authorizedParty) && $authorizedParty !== '') { - return $this->hasPersistedClientRegistration($authorizedParty) ? $authorizedParty : null; + return $this->loadPersistedClientRegistration($authorizedParty); } foreach ($audiences as $audience) { - if (is_string($audience) && $audience !== '' && $this->hasPersistedClientRegistration($audience)) { - return $audience; + if ( + is_string($audience) && + $audience !== '' && + ($registrationData = $this->loadPersistedClientRegistration($audience)) + instanceof \Cicnavi\Oidc\Registration\ClientRegistrationData + ) { + return $registrationData; } } @@ -910,19 +936,26 @@ protected function resolveRegisteredClientIdForLogout(array $audiences, ?string } /** - * Whether a client registration for the given client ID is persisted - - * either the current registration or a retained per-client entry of a - * replaced registration. Registration entries are only read; no client - * registration is performed or updated. + * Load a persisted client registration for the given client ID - either the + * current registration or a retained per-client entry of a replaced + * registration - or null when none is persisted (or it can not be read). + * Registration entries are only read; no client registration is performed + * or updated. */ - protected function hasPersistedClientRegistration(string $clientId): bool + protected function loadPersistedClientRegistration(string $clientId): ?ClientRegistrationData { - if ($clientId === $this->loadRegistrationData()?->getClientId()) { - return true; + $currentRegistrationData = $this->loadRegistrationData(); + if ( + $currentRegistrationData instanceof ClientRegistrationData && + $clientId === $currentRegistrationData->getClientId() + ) { + return $currentRegistrationData; } try { - return $this->registrationStore->get($this->getClientRegistrationStoreKey($clientId)) !== null; + $claims = $this->registrationStore->get($this->getClientRegistrationStoreKey($clientId)); + + return is_array($claims) ? new ClientRegistrationData($claims) : null; } catch (Throwable $throwable) { $this->logger?->warning( 'Error reading persisted client registration while resolving a back-channel ' . @@ -930,7 +963,7 @@ protected function hasPersistedClientRegistration(string $clientId): bool ['clientId' => $clientId], ); - return false; + return null; } } diff --git a/src/FederatedClient.php b/src/FederatedClient.php index 0d8c2b6..faaa42c 100644 --- a/src/FederatedClient.php +++ b/src/FederatedClient.php @@ -1070,6 +1070,7 @@ public function handleBackchannelLogoutRequest( expectedIssuer: $issuer, expectedClientId: $this->entityConfig->getEntityId(), expectedSigningAlgorithm: $this->idTokenSignedResponseAlg, + requireSid: $this->isBackchannelLogoutSessionRequired(), ); $this->requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); @@ -1088,6 +1089,19 @@ public function handleBackchannelLogoutRequest( return HttpHelper::dispatchBackchannelLogoutResponse($response, null, $this->logger); } + /** + * Whether this RP publishes 'backchannel_logout_session_required' as true + * in its OpenID Relying Party federation metadata (via RelyingPartyConfig + * additional claims). When true, OIDC Back-Channel Logout tokens without a + * 'sid' claim are rejected, since the RP declared it always needs the exact + * session identified rather than falling back to a subject-wide logout. + */ + protected function isBackchannelLogoutSessionRequired(): bool + { + return $this->relyingPartyConfig->getAdditionalClaimBag() + ->get(ClaimsEnum::BackChannelLogoutSessionRequired->value) === true; + } + /** * Resolve OpenID Provider metadata for the given OP entity ID by * resolving a federation Trust Chain to it (using the configured Trust diff --git a/src/PreRegisteredClient.php b/src/PreRegisteredClient.php index 6a4dbd8..139aa0c 100644 --- a/src/PreRegisteredClient.php +++ b/src/PreRegisteredClient.php @@ -99,6 +99,11 @@ class PreRegisteredClient * Logout tokens signed with a different algorithm are rejected. Defaults * to 'RS256' (the OpenID Connect default). Set to null to accept any * supported algorithm. + * @param bool $backchannelLogoutSessionRequired Whether this client was + * registered on the OP with 'backchannel_logout_session_required' true. + * When true, OIDC Back-Channel Logout tokens without a 'sid' claim are + * rejected (the OP is then expected to always identify the exact session + * to terminate, rather than falling back to a subject-wide logout). * @throws CacheException If cache could not be initialized. * @throws OidcClientException If cache could not be reinitialized. */ @@ -143,6 +148,7 @@ public function __construct( ?RequestDataHandler $requestDataHandler = null, protected readonly ParModeEnum $parMode = ParModeEnum::Auto, protected readonly ?string $idTokenSignedResponseAlg = SignatureAlgorithmEnum::RS256->value, + protected readonly bool $backchannelLogoutSessionRequired = false, ) { $this->validateResponseMode($this->responseMode); @@ -499,6 +505,7 @@ public function handleBackchannelLogoutRequest( expectedIssuer: MetadataHelper::optionalString($this->metadata, ClaimsEnum::Issuer->value), expectedClientId: $this->clientId, expectedSigningAlgorithm: $this->idTokenSignedResponseAlg, + requireSid: $this->backchannelLogoutSessionRequired, ); $this->requestDataHandler->registerLogoutTokenRevocation($logoutTokenJws); diff --git a/tests/Oidc/DynamicallyRegisteredClientTest.php b/tests/Oidc/DynamicallyRegisteredClientTest.php index a580737..410ac87 100644 --- a/tests/Oidc/DynamicallyRegisteredClientTest.php +++ b/tests/Oidc/DynamicallyRegisteredClientTest.php @@ -1258,10 +1258,12 @@ public function testHandleBackchannelLogoutRequestUsesAuthorizedPartyToDisambigu $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); } - public function testHandleBackchannelLogoutRequestRequiresSidWhenSessionRequired(): void + public function testHandleBackchannelLogoutRequestRequiresSidWhenRegistrationRequiresIt(): void { - // A client registered with 'backchannel_logout_session_required' true - // must have logout tokens validated with the 'sid' requirement on. + // The matched registration's own persisted metadata declares + // 'backchannel_logout_session_required' true (as the OP echoes it back + // at registration), so logout tokens are validated with the 'sid' + // requirement on. $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); $this->requestDataHandlerMock->method('parseBackchannelLogoutTokenAudienceInfo') ->willReturn(['audiences' => ['registered-client-id'], 'authorizedParty' => null]); @@ -1274,6 +1276,7 @@ public function testHandleBackchannelLogoutRequestRequiresSidWhenSessionRequired $this->registrationStoreMock->method('get')->willReturn([ 'client_id' => 'registered-client-id', 'client_secret' => 'client-secret', + 'backchannel_logout_session_required' => true, ]); $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); @@ -1296,42 +1299,51 @@ public function testHandleBackchannelLogoutRequestRequiresSidWhenSessionRequired $response->expects($this->once())->method('withStatus')->with(200)->willReturn($response); $response->method('withHeader')->willReturn($response); - $sut = $this->sut( - backchannelLogoutUri: 'https://rp.example.org/backchannel-logout', - backchannelLogoutSessionRequired: true, - ); - $this->assertSame($response, $sut->handleBackchannelLogoutRequest(null, $response)); + $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); } - public function testHandleBackchannelLogoutRequestRequiresSidWhenSessionRequiredViaAdditionalMetadata(): void + public function testHandleBackchannelLogoutRequestUsesReplacedRegistrationPolicy(): void { - // 'backchannel_logout_session_required' can also be registered through - // additionalClientMetadata (which overrides the constructor option); - // the 'sid' requirement must be derived from the effective metadata. + // A logout token arrives for an old (replaced) client whose retained + // per-client entry was registered with a different signing algorithm + // and 'sid' policy than the current registration. Validation must use + // the OLD registration's algorithm and 'sid' requirement, not the + // current ones. $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); $this->requestDataHandlerMock->method('parseBackchannelLogoutTokenAudienceInfo') - ->willReturn(['audiences' => ['registered-client-id'], 'authorizedParty' => null]); + ->willReturn(['audiences' => ['old-client-id'], 'authorizedParty' => null]); $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ ['jwks_uri', 'https://op.example.org/jwks'], ['issuer', 'https://op.example.org'], ]); - $this->registrationStoreMock->method('get')->willReturn([ - 'client_id' => 'registered-client-id', - 'client_secret' => 'client-secret', + $mainKey = $this->sut()->getRegistrationStoreKey(); + $oldClientKey = $this->sut()->getClientRegistrationStoreKey('old-client-id'); + + $this->registrationStoreMock->expects($this->exactly(2))->method('get')->willReturnMap([ + // Current registration: default RS256, no 'sid' requirement. + [$mainKey, ['client_id' => 'new-client-id', 'client_secret' => 'new-secret']], + // Old (replaced) registration: registered with ES256 and 'sid' required. + [$oldClientKey, [ + 'client_id' => 'old-client-id', + 'client_secret' => 'old-secret', + 'id_token_signed_response_alg' => 'ES256', + 'backchannel_logout_session_required' => true, + ]], ]); $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + // Validated with the OLD registration's ES256 + 'sid'-required policy. $this->requestDataHandlerMock->expects($this->once()) ->method('validateLogoutToken') ->with( 'logout-token', 'https://op.example.org/jwks', 'https://op.example.org', - 'registered-client-id', - 'RS256', + 'old-client-id', + 'ES256', true, ) ->willReturn($logoutTokenJws); @@ -1342,8 +1354,6 @@ public function testHandleBackchannelLogoutRequestRequiresSidWhenSessionRequired $response->expects($this->once())->method('withStatus')->with(200)->willReturn($response); $response->method('withHeader')->willReturn($response); - // No dedicated constructor option - only the additionalClientMetadata override. - $sut = $this->sut(additionalClientMetadata: ['backchannel_logout_session_required' => true]); - $this->assertSame($response, $sut->handleBackchannelLogoutRequest(null, $response)); + $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); } } diff --git a/tests/Oidc/FederatedClientTest.php b/tests/Oidc/FederatedClientTest.php index c460576..990962b 100644 --- a/tests/Oidc/FederatedClientTest.php +++ b/tests/Oidc/FederatedClientTest.php @@ -1261,6 +1261,59 @@ public function testHandleBackchannelLogoutRequestSuccess(): void ); } + public function testHandleBackchannelLogoutRequestRequiresSidWhenSessionRequired(): void + { + $opEntityId = 'https://op.example.org'; + + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + $this->mockLogoutTokenWithIssuer($opEntityId); + + $trustAnchorBagMock = $this->createMock(TrustAnchorConfigBag::class); + $trustAnchorBagMock->method('getAllEntityIds')->willReturn(['https://ta.example.org']); + $this->entityConfigMock->method('getTrustAnchorBag')->willReturn($trustAnchorBagMock); + $this->entityConfigMock->method('getEntityId')->willReturn('https://rp.example.org'); + + $opTrustChainBagMock = $this->createMock(TrustChainBag::class); + $trustChainResolverMock = $this->createMock(TrustChainResolver::class); + $this->federationMock->method('trustChainResolver')->willReturn($trustChainResolverMock); + $trustChainResolverMock->method('for')->willReturn($opTrustChainBagMock); + $opTrustChainMock = $this->createMock(TrustChain::class); + $opTrustChainBagMock->method('getShortest')->willReturn($opTrustChainMock); + $opTrustChainMock->method('getResolvedMetadata') + ->willReturn(['jwks_uri' => 'https://op.example.org/jwks']); + + // The RP publishes 'backchannel_logout_session_required' true in its + // OpenID Relying Party federation metadata (additional claims). + $this->realyingPartyConfigMock->method('getAdditionalClaimBag') + ->willReturn(new ClaimBag(['backchannel_logout_session_required' => true])); + + $validatedLogoutTokenStub = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + + // Validated with the 'sid' requirement on (last argument true). + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutToken') + ->with( + 'logout-token', + 'https://op.example.org/jwks', + $opEntityId, + 'https://rp.example.org', + $this->anything(), + true, + ) + ->willReturn($validatedLogoutTokenStub); + + $this->requestDataHandlerMock->expects($this->once())->method('registerLogoutTokenRevocation'); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->expects($this->once())->method('withStatus')->with(200)->willReturn($responseMock); + $responseMock->method('withHeader')->willReturn($responseMock); + + $this->assertSame( + $responseMock, + $this->sut()->handleBackchannelLogoutRequest(null, $responseMock), + ); + } + public function testHandleBackchannelLogoutRequestRespondsWith400OnTrustChainError(): void { $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); diff --git a/tests/Oidc/PreRegisteredClientTest.php b/tests/Oidc/PreRegisteredClientTest.php index 9e100b4..1c19b87 100644 --- a/tests/Oidc/PreRegisteredClientTest.php +++ b/tests/Oidc/PreRegisteredClientTest.php @@ -149,6 +149,7 @@ protected function sut( ?AuthorizationRequestMethodEnum $defaultAuthorizationRequestMethod = null, ?ResponseModesEnum $responseMode = null, ?RequestDataHandler $requestDataHandler = null, + bool $backchannelLogoutSessionRequired = false, ): PreRegisteredClient { return new PreRegisteredClient( $opConfigurationUrl ?? $this->opConfigrationUrl, @@ -175,6 +176,7 @@ protected function sut( $defaultAuthorizationRequestMethod ?? $this->defaultAuthorizationRequestMethod, $responseMode, $requestDataHandler ?? $this->requestDataHandlerMock, + backchannelLogoutSessionRequired: $backchannelLogoutSessionRequired, ); } @@ -808,6 +810,41 @@ public function testHandleBackchannelLogoutRequestSuccess(): void $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest($request, $response)); } + public function testHandleBackchannelLogoutRequestRequiresSidWhenSessionRequired(): void + { + // A client manually registered with 'backchannel_logout_session_required' + // true must have logout tokens validated with the 'sid' requirement on. + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ + ['jwks_uri', 'https://op.example.org/jwks'], + ['issuer', 'https://op.example.org'], + ]); + + $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutToken') + ->with( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + $this->clientId, + 'RS256', + true, + ) + ->willReturn($logoutTokenJws); + + $this->requestDataHandlerMock->expects($this->once())->method('registerLogoutTokenRevocation'); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(200)->willReturn($response); + $response->method('withHeader')->willReturn($response); + + $sut = $this->sut(backchannelLogoutSessionRequired: true); + $this->assertSame($response, $sut->handleBackchannelLogoutRequest(null, $response)); + } + public function testHandleBackchannelLogoutRequestRespondsWith400OnInvalidLogoutToken(): void { $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); From 9d971fe91ce8a51ab3e46c9512e337a681d1d5cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 6 Jul 2026 17:08:27 +0200 Subject: [PATCH 10/10] Hanlde alg requirement on replaced registrations --- src/DynamicallyRegisteredClient.php | 26 +++++++-- .../Oidc/DynamicallyRegisteredClientTest.php | 55 +++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/DynamicallyRegisteredClient.php b/src/DynamicallyRegisteredClient.php index 28abcf6..6a78f5e 100644 --- a/src/DynamicallyRegisteredClient.php +++ b/src/DynamicallyRegisteredClient.php @@ -870,16 +870,32 @@ public function handleBackchannelLogoutRequest( /** * The signing algorithm the OP uses for the given registration's ID and * logout tokens, read from that registration's own persisted metadata - * ('id_token_signed_response_alg'). Falls back to this client's configured - * 'idTokenSignedResponseAlg' when the registration does not carry the claim - * (e.g. it was not registered explicitly), so validation still applies the - * expected default (RS256). + * ('id_token_signed_response_alg'). + * + * When the registration does not carry the claim (e.g. it was registered + * with the default algorithm, and the OP did not echo it back), the + * fallback depends on which registration this is: + * - for the CURRENT registration, this client's configured + * 'idTokenSignedResponseAlg' applies (the expected algorithm for the live + * client); + * - for a retained (replaced) registration, whose original configuration is + * not known here, the OpenID Connect default 'RS256' applies - NOT the + * current configuration, since a later config change (e.g. to 'ES256') + * must not reject valid logout tokens for old-client sessions that were + * signed with the previously effective algorithm. */ protected function registeredIdTokenSignedResponseAlg(ClientRegistrationData $registrationData): ?string { $alg = $registrationData->getClaims()[ClaimsEnum::IdTokenSignedResponseAlg->value] ?? null; + if (is_string($alg) && $alg !== '') { + return $alg; + } + + if ($registrationData->getClientId() === $this->loadRegistrationData()?->getClientId()) { + return $this->idTokenSignedResponseAlg; + } - return (is_string($alg) && $alg !== '') ? $alg : $this->idTokenSignedResponseAlg; + return SignatureAlgorithmEnum::RS256->value; } /** diff --git a/tests/Oidc/DynamicallyRegisteredClientTest.php b/tests/Oidc/DynamicallyRegisteredClientTest.php index 410ac87..7e62ea7 100644 --- a/tests/Oidc/DynamicallyRegisteredClientTest.php +++ b/tests/Oidc/DynamicallyRegisteredClientTest.php @@ -94,6 +94,7 @@ protected function sut( bool $injectRequestDataHandler = true, ?string $backchannelLogoutUri = null, ?bool $backchannelLogoutSessionRequired = null, + ?string $idTokenSignedResponseAlg = 'RS256', ): DynamicallyRegisteredClient { $registrationStore ??= $this->registrationStoreMock; $cache ??= $this->cacheMock; @@ -132,6 +133,7 @@ protected function sut( postLogoutRedirectUris: $postLogoutRedirectUris, backchannelLogoutUri: $backchannelLogoutUri, backchannelLogoutSessionRequired: $backchannelLogoutSessionRequired, + idTokenSignedResponseAlg: $idTokenSignedResponseAlg, ); } @@ -1356,4 +1358,57 @@ public function testHandleBackchannelLogoutRequestUsesReplacedRegistrationPolicy $this->assertSame($response, $this->sut()->handleBackchannelLogoutRequest(null, $response)); } + + public function testHandleBackchannelLogoutRequestUsesRs256DefaultForOldRegistrationWithoutAlg(): void + { + // An old (replaced) registration whose stored client information + // response does NOT carry 'id_token_signed_response_alg' (it used the + // default). The current client is now configured with ES256, but the + // old-client logout token must be validated against the OpenID Connect + // default RS256 - the old registration's effective algorithm - not the + // current ES256 configuration. + $this->requestDataHandlerMock->method('parseBackchannelLogoutRequest')->willReturn('logout-token'); + $this->requestDataHandlerMock->method('parseBackchannelLogoutTokenAudienceInfo') + ->willReturn(['audiences' => ['old-client-id'], 'authorizedParty' => null]); + + $this->metadataMock->expects($this->exactly(2))->method('get')->willReturnMap([ + ['jwks_uri', 'https://op.example.org/jwks'], + ['issuer', 'https://op.example.org'], + ]); + + $mainKey = $this->sut()->getRegistrationStoreKey(); + $oldClientKey = $this->sut()->getClientRegistrationStoreKey('old-client-id'); + + $this->registrationStoreMock->expects($this->exactly(2))->method('get')->willReturnMap([ + // Current registration (different client ID). + [$mainKey, ['client_id' => 'new-client-id', 'client_secret' => 'new-secret']], + // Old registration without an explicit signing algorithm claim. + [$oldClientKey, ['client_id' => 'old-client-id', 'client_secret' => 'old-secret']], + ]); + + $logoutTokenJws = $this->createStub(\SimpleSAML\OpenID\Core\LogoutToken::class); + + // Validated against RS256, not the current ES256 configuration. + $this->requestDataHandlerMock->expects($this->once()) + ->method('validateLogoutToken') + ->with( + 'logout-token', + 'https://op.example.org/jwks', + 'https://op.example.org', + 'old-client-id', + 'RS256', + false, + ) + ->willReturn($logoutTokenJws); + + $this->requestDataHandlerMock->expects($this->once())->method('registerLogoutTokenRevocation'); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once())->method('withStatus')->with(200)->willReturn($response); + $response->method('withHeader')->willReturn($response); + + // Current client is configured with ES256. + $sut = $this->sut(idTokenSignedResponseAlg: 'ES256'); + $this->assertSame($response, $sut->handleBackchannelLogoutRequest(null, $response)); + } }