From 49128de12beebdfcb0f42a4e55138a335bceaf78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 8 Jun 2026 20:23:24 +0200 Subject: [PATCH 01/11] WIP --- config/module_oidc.php.dist | 9 + hooks/hook_cron.php | 5 + routing/routes/routes.php | 5 + src/Codebooks/RoutesEnum.php | 1 + src/Controllers/Admin/ClientController.php | 9 + .../PushedAuthorizationController.php | 201 +++++++++++++++++ src/Entities/ClientEntity.php | 47 ++++ .../Interfaces/ClientEntityInterface.php | 6 + .../PushedAuthorizationRequestEntity.php | 78 +++++++ src/Factories/AuthorizationServerFactory.php | 16 ++ src/Factories/Grant/AuthCodeGrantFactory.php | 3 + .../Grant/PreAuthCodeGrantFactory.php | 3 + src/Factories/RequestRulesManagerFactory.php | 7 +- src/Forms/ClientForm.php | 35 ++- src/ModuleConfig.php | 33 +++ .../PushedAuthorizationRequestRepository.php | 100 +++++++++ src/Server/AuthorizationServer.php | 206 +++++++++++++++++- src/Server/Grants/AuthCodeGrant.php | 7 + .../RequestRules/Rules/RequestObjectRule.php | 28 ++- .../RequestTypes/AuthorizationRequest.php | 12 + src/Services/Container.php | 21 +- src/Services/DatabaseMigration.php | 29 +++ src/Services/OpMetadataService.php | 6 +- src/Utils/Routes.php | 6 + .../PushedAuthorizationControllerTest.php | 174 +++++++++++++++ tests/unit/src/Entities/ClientEntityTest.php | 3 + .../src/Server/Grants/AuthCodeGrantTest.php | 10 +- .../Rules/RequestObjectRuleTest.php | 54 ++++- .../src/Services/OpMetadataServiceTest.php | 5 +- 29 files changed, 1092 insertions(+), 27 deletions(-) create mode 100644 src/Controllers/PushedAuthorizationController.php create mode 100644 src/Entities/PushedAuthorizationRequestEntity.php create mode 100644 src/Repositories/PushedAuthorizationRequestRepository.php create mode 100644 tests/unit/src/Controllers/PushedAuthorizationControllerTest.php diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index bafdff71..72f54ca6 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -120,6 +120,15 @@ $config = [ */ ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY => 'PT1M', + /** + * Pushed Authorization Request (PAR) and Request Object URL (JAR) configurations. + */ + ModuleConfig::OPTION_PAR_REQUEST_URI_TTL => 'PT10M', // PAR request URI expiration TTL (default: 10 minutes) + ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS => false, // Require PAR globally (default: false) + ModuleConfig::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT => false, // Reject unsigned request objects globally (default: false) + ModuleConfig::OPTION_REQUEST_URI_TIMEOUT => 5, // Timeout for fetching request_uri (default: 5 seconds) + ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES => 102400, // Maximum allowed response size for request_uri in bytes (default: 100KB) + /** * The default authentication source to be used for authentication if the * authentication source is not specified on a particular client. diff --git a/hooks/hook_cron.php b/hooks/hook_cron.php index f1520849..7751baba 100644 --- a/hooks/hook_cron.php +++ b/hooks/hook_cron.php @@ -19,6 +19,7 @@ use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\Container; @@ -69,6 +70,10 @@ function oidc_hook_cron(array &$croninfo): void $issuerStateRepository = $container->get(IssuerStateRepository::class); $issuerStateRepository->removeInvalid(); + /** @var \SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository $pushedAuthRepo */ + $pushedAuthRepo = $container->get(PushedAuthorizationRequestRepository::class); + $pushedAuthRepo->deleteExpired(new DateTimeImmutable()); + $croninfo['summary'][] = 'Module `oidc` clean up. Removed expired entries from storage.'; } catch (Exception $e) { $message = 'Module `oidc` clean up cron script failed: ' . $e->getMessage(); diff --git a/routing/routes/routes.php b/routing/routes/routes.php index b14f724c..0be7317f 100644 --- a/routing/routes/routes.php +++ b/routing/routes/routes.php @@ -20,6 +20,7 @@ use SimpleSAML\Module\oidc\Controllers\JwksController; use SimpleSAML\Module\oidc\Controllers\OAuth2\OAuth2ServerConfigurationController; use SimpleSAML\Module\oidc\Controllers\OAuth2\TokenIntrospectionController; +use SimpleSAML\Module\oidc\Controllers\PushedAuthorizationController; use SimpleSAML\Module\oidc\Controllers\UserInfoController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController; use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerCredentialController; @@ -111,6 +112,10 @@ $routes->add(RoutesEnum::OAuth2Configuration->name, RoutesEnum::OAuth2Configuration->value) ->controller(OAuth2ServerConfigurationController::class); + $routes->add(RoutesEnum::PushedAuthorizationRequest->name, RoutesEnum::PushedAuthorizationRequest->value) + ->controller([PushedAuthorizationController::class, 'par']) + ->methods([HttpMethodsEnum::POST->value]); + /***************************************************************************************************************** * OpenID Federation ****************************************************************************************************************/ diff --git a/src/Codebooks/RoutesEnum.php b/src/Codebooks/RoutesEnum.php index f08f3ca8..9f86c543 100644 --- a/src/Codebooks/RoutesEnum.php +++ b/src/Codebooks/RoutesEnum.php @@ -49,6 +49,7 @@ enum RoutesEnum: string // OAuth 2.0 Authorization Server Metadata https://www.rfc-editor.org/rfc/rfc8414.html case OAuth2Configuration = '.well-known/oauth-authorization-server'; + case PushedAuthorizationRequest = 'par'; /***************************************************************************************************************** * OpenID Federation diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php index 3251e661..7f1aaade 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -354,8 +354,17 @@ protected function buildClientEntityFromFormData( $data[ClaimsEnum::IdTokenSignedResponseAlg->value] : null; + $requirePushedAuth = (bool)($data['require_pushed_authorization_requests'] ?? false); + $requireSignedReqObj = (bool)($data['require_signed_request_object'] ?? false); + /** @var mixed $rawRequestUris */ + $rawRequestUris = $data['request_uris'] ?? null; + $requestUris = is_array($rawRequestUris) ? $rawRequestUris : []; + $extraMetadata = [ ClaimsEnum::IdTokenSignedResponseAlg->value => $idTokenSignedResponseAlg, + 'require_pushed_authorization_requests' => $requirePushedAuth, + 'require_signed_request_object' => $requireSignedReqObj, + 'request_uris' => $requestUris, ]; $allowedResponseModes = is_array($data[ClientEntity::KEY_ALLOWED_RESPONSE_MODES]) ? diff --git a/src/Controllers/PushedAuthorizationController.php b/src/Controllers/PushedAuthorizationController.php new file mode 100644 index 00000000..527cc745 --- /dev/null +++ b/src/Controllers/PushedAuthorizationController.php @@ -0,0 +1,201 @@ +logger->debug('PushedAuthorizationController::__invoke'); + + if (strtoupper($request->getMethod()) !== 'POST') { + return $this->psrHttpBridge->getResponseFactory()->createResponse() + ->withStatus(405) + ->withHeader('Allow', 'POST'); + } + + // 1. Authenticate client + $resolvedAuth = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod($request); + if (is_null($resolvedAuth)) { + throw OidcServerException::accessDenied('Client authentication failed'); + } + + $client = $resolvedAuth->getClient(); + + if ($resolvedAuth->getClientAuthenticationMethod()->isNone() && $client->isConfidential()) { + throw OidcServerException::accessDenied('Confidential client must authenticate.'); + } + + // 2. Parse request params + $bodyParams = $request->getParsedBody(); + $params = is_array($bodyParams) ? $bodyParams : []; + + // 3. Reject request_uri in PAR body + if (isset($params['request_uri'])) { + throw OidcServerException::invalidRequest( + 'request_uri', + 'The request_uri parameter MUST NOT be provided in pushed authorization requests.', + ); + } + + // 4. Handle JAR in PAR (request parameter) + if (isset($params['request'])) { + try { + $requestObject = $this->core->jarRequestObjectFactory()->fromToken((string)$params['request']); + $jwks = $this->jwksResolver->forClient($client); + if (is_null($jwks)) { + throw OidcServerException::invalidRequest( + 'request', + 'Client JWKS not available for signature verification.', + ); + } + $requestObject->verifyWithKeySet($jwks); + + if ($requestObject->getClientId() !== $client->getIdentifier()) { + throw OidcServerException::invalidRequest( + 'request', + 'Client ID in request object does not match authenticated client.', + ); + } + + $params = array_merge($params, $requestObject->getPayload()); + unset($params['request']); + } catch (\Throwable $t) { + throw OidcServerException::invalidRequest('request', 'Invalid request object: ' . $t->getMessage()); + } + } + + // 5. Build mock request with merged params and run validation rules + $psrRequest = $request->withParsedBody($params)->withQueryParams([]); + + $resultBag = new ResultBag(); + $resultBag->add(new Result(ClientRule::class, $client)); + + $this->requestRulesManager->predefineResultBag($resultBag); + + $rulesToExecute = [ + StateRule::class, + ClientRedirectUriRule::class, + ResponseModeRule::class, + ScopeRule::class, + RequiredOpenIdScopeRule::class, + CodeChallengeRule::class, + CodeChallengeMethodRule::class, + ]; + + $this->requestRulesManager->setData('default_scope', ''); + $this->requestRulesManager->setData('scope_delimiter_string', ' '); + + $this->requestRulesManager->check( + $psrRequest, + $rulesToExecute, + new QueryResponseMode(), + [HttpMethodsEnum::POST], + ); + + // 6. Generate request_uri + $hex = bin2hex(random_bytes(32)); + $requestUri = 'urn:ietf:params:oauth:request_uri:' . $hex; + + // 7. Persist entity + $ttl = $this->moduleConfig->getParRequestUriTtl(); + $expiresAt = $this->helpers->dateTime()->getUtc()->add($ttl); + + // Make sure we carry forward all validated params + $entity = new PushedAuthorizationRequestEntity( + requestUri: $requestUri, + clientId: $client->getIdentifier(), + parameters: $params, + expiresAt: \DateTimeImmutable::createFromInterface($expiresAt), + isConsumed: false, + ); + + $this->pushedAuthorizationRequestRepository->persist($entity); + + // 8. Respond + $expiresIn = $this->helpers->dateTime()->getSecondsToExpirationTime($expiresAt->getTimestamp()); + $responseBody = json_encode([ + 'request_uri' => $requestUri, + 'expires_in' => $expiresIn, + ], JSON_THROW_ON_ERROR); + + $response = $this->psrHttpBridge->getResponseFactory()->createResponse() + ->withStatus(201) + ->withHeader('Cache-Control', 'no-cache, no-store') + ->withHeader('Content-Type', 'application/json'); + + $response->getBody()->write($responseBody); + + return $response; + } + + public function par(Request $request): Response + { + try { + $psrRequest = $this->psrHttpBridge->getPsrHttpFactory()->createRequest($request); + $psrResponse = $this->__invoke($psrRequest); + return $this->psrHttpBridge->getHttpFoundationFactory()->createResponse($psrResponse); + } catch (OAuthServerException $exception) { + return $this->errorResponder->forException($exception); + } catch (\Throwable $exception) { + return $this->errorResponder->forException( + OidcServerException::invalidRequest('request', $exception->getMessage()), + ); + } + } +} diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php index d6de1b6d..2ff79ff0 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -232,6 +232,9 @@ public function toArray(): array // Extra metadata ClaimsEnum::IdTokenSignedResponseAlg->value => $this->getIdTokenSignedResponseAlg(), + 'require_pushed_authorization_requests' => $this->getRequirePushedAuthorizationRequests(), + 'require_signed_request_object' => $this->getRequireSignedRequestObject(), + 'request_uris' => $this->getRequestUris(), ]; } @@ -406,4 +409,48 @@ public function getAllowedResponseModes(): array ResponseModesEnum::FormPost->value, ]; } + + public function getRequirePushedAuthorizationRequests(): bool + { + if (!is_array($this->extraMetadata)) { + return false; + } + + return (bool)($this->extraMetadata['require_pushed_authorization_requests'] ?? false); + } + + public function getRequireSignedRequestObject(): bool + { + if (!is_array($this->extraMetadata)) { + return false; + } + + return (bool)($this->extraMetadata['require_signed_request_object'] ?? false); + } + + /** + * @return string[] + */ + public function getRequestUris(): array + { + if (!is_array($this->extraMetadata)) { + return []; + } + + /** @var mixed $uris */ + $uris = $this->extraMetadata['request_uris'] ?? null; + if (!is_array($uris)) { + return []; + } + + $stringUris = []; + /** @var mixed $uri */ + foreach ($uris as $uri) { + if (is_string($uri)) { + $stringUris[] = $uri; + } + } + + return $stringUris; + } } diff --git a/src/Entities/Interfaces/ClientEntityInterface.php b/src/Entities/Interfaces/ClientEntityInterface.php index 47bc6f15..9ca01687 100644 --- a/src/Entities/Interfaces/ClientEntityInterface.php +++ b/src/Entities/Interfaces/ClientEntityInterface.php @@ -83,4 +83,10 @@ public function isGeneric(): bool; public function getExtraMetadata(): array; public function getIdTokenSignedResponseAlg(): ?string; public function getAllowedResponseModes(): array; + public function getRequirePushedAuthorizationRequests(): bool; + public function getRequireSignedRequestObject(): bool; + /** + * @return string[] + */ + public function getRequestUris(): array; } diff --git a/src/Entities/PushedAuthorizationRequestEntity.php b/src/Entities/PushedAuthorizationRequestEntity.php new file mode 100644 index 00000000..7046ede3 --- /dev/null +++ b/src/Entities/PushedAuthorizationRequestEntity.php @@ -0,0 +1,78 @@ +requestUri; + } + + public function getClientId(): string + { + return $this->clientId; + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function isConsumed(): bool + { + return $this->isConsumed; + } + + public function consume(): void + { + $this->isConsumed = true; + } + + public function isExpired(): bool + { + return $this->expiresAt < new DateTimeImmutable(); + } + + /** + * @throws \JsonException + */ + public function getState(): array + { + return [ + 'request_uri' => $this->requestUri, + 'client_id' => $this->clientId, + 'parameters' => json_encode($this->parameters, JSON_THROW_ON_ERROR), + 'expires_at' => $this->expiresAt->format('Y-m-d H:i:s'), + 'is_consumed' => $this->isConsumed, + ]; + } +} diff --git a/src/Factories/AuthorizationServerFactory.php b/src/Factories/AuthorizationServerFactory.php index 9970b948..90cb5a7e 100644 --- a/src/Factories/AuthorizationServerFactory.php +++ b/src/Factories/AuthorizationServerFactory.php @@ -20,6 +20,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\ScopeRepository; use SimpleSAML\Module\oidc\Server\AuthorizationServer; use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; @@ -29,6 +30,10 @@ use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Utils\JwksResolver; +use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Utils\RequestUriFetcher; class AuthorizationServerFactory { @@ -45,6 +50,11 @@ public function __construct( private readonly CryptKey $privateKey, private readonly PreAuthCodeGrant $preAuthCodeGrant, private readonly LoggerService $loggerService, + private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, + private readonly RequestParamsResolver $requestParamsResolver, + private readonly JwksResolver $jwksResolver, + private readonly RequestUriFetcher $requestUriFetcher, + private readonly Core $core, ) { } @@ -59,6 +69,12 @@ public function build(): AuthorizationServer $this->tokenResponse, $this->requestRulesManager, $this->loggerService, + $this->pushedAuthorizationRequestRepository, + $this->requestParamsResolver, + $this->jwksResolver, + $this->requestUriFetcher, + $this->core, + $this->moduleConfig, ); $authorizationServer->enableGrantType( diff --git a/src/Factories/Grant/AuthCodeGrantFactory.php b/src/Factories/Grant/AuthCodeGrantFactory.php index 5b90a452..f0327e7d 100644 --- a/src/Factories/Grant/AuthCodeGrantFactory.php +++ b/src/Factories/Grant/AuthCodeGrantFactory.php @@ -22,6 +22,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; @@ -43,6 +44,7 @@ public function __construct( private readonly RefreshTokenIssuer $refreshTokenIssuer, private readonly Helpers $helpers, private readonly LoggerService $loggerService, + private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, ) { } @@ -63,6 +65,7 @@ public function build(): AuthCodeGrant $this->refreshTokenIssuer, $this->helpers, $this->loggerService, + $this->pushedAuthorizationRequestRepository, ); $authCodeGrant->setRefreshTokenTTL($this->moduleConfig->getRefreshTokenDuration()); diff --git a/src/Factories/Grant/PreAuthCodeGrantFactory.php b/src/Factories/Grant/PreAuthCodeGrantFactory.php index 9e241c3f..2b9d7720 100644 --- a/src/Factories/Grant/PreAuthCodeGrantFactory.php +++ b/src/Factories/Grant/PreAuthCodeGrantFactory.php @@ -22,6 +22,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Server\Grants\PreAuthCodeGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; @@ -43,6 +44,7 @@ public function __construct( private readonly RefreshTokenIssuer $refreshTokenIssuer, private readonly Helpers $helpers, private readonly LoggerService $loggerService, + private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, ) { } @@ -63,6 +65,7 @@ public function build(): PreAuthCodeGrant $this->refreshTokenIssuer, $this->helpers, $this->loggerService, + $this->pushedAuthorizationRequestRepository, ); $preAuthCodeGrant->setRefreshTokenTTL($this->moduleConfig->getRefreshTokenDuration()); diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index 952485d4..33e7df74 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -113,7 +113,12 @@ private function getDefaultRules(): array $this->federationCache, ), new ClientRedirectUriRule($this->requestParamsResolver, $this->helpers, $this->moduleConfig), - new RequestObjectRule($this->requestParamsResolver, $this->helpers, $this->jwksResolver), + new RequestObjectRule( + $this->requestParamsResolver, + $this->helpers, + $this->jwksResolver, + $this->moduleConfig, + ), new ResponseModeRule( $this->requestParamsResolver, $this->helpers, diff --git a/src/Forms/ClientForm.php b/src/Forms/ClientForm.php index c76b18bc..b6b2081a 100644 --- a/src/Forms/ClientForm.php +++ b/src/Forms/ClientForm.php @@ -178,6 +178,18 @@ public function validateJwksUri(Form $form): void } } + + public function validateRequestUris(Form $form): void + { + $values = $form->getValues(self::TYPE_ARRAY); + $requestUris = $this->helpers->str()->convertTextToArray((string)($values['request_uris'] ?? '')); + foreach ($requestUris as $uri) { + if (!str_starts_with(strtolower($uri), 'https://')) { + $this->addError('Request URI must be an HTTPS URL: ' . $uri); + } + } + } + public function validateJwks(mixed $jwks): void { if (is_null($jwks)) { @@ -217,7 +229,8 @@ protected function validateByMatchingRegex( public function getValues(string|object|bool|null $returnType = null, ?array $controls = null): array { - $values = parent::getValues(self::TYPE_ARRAY); + /** @psalm-suppress RedundantCast */ + $values = (array)parent::getValues(self::TYPE_ARRAY); // Sanitize redirect_uri and allowed_origin $values['redirect_uri'] = $this->helpers->str()->convertTextToArray((string)$values['redirect_uri']); @@ -277,6 +290,8 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co $signedJwksUri = trim((string)$values['signed_jwks_uri']); $values['signed_jwks_uri'] = empty($signedJwksUri) ? null : $signedJwksUri; + $values['request_uris'] = $this->helpers->str()->convertTextToArray((string)($values['request_uris'] ?? '')); + $idTokenSignedResponseAlg = trim((string)$values[ClaimsEnum::IdTokenSignedResponseAlg->value]); $values[ClaimsEnum::IdTokenSignedResponseAlg->value] = empty($idTokenSignedResponseAlg) ? null : $idTokenSignedResponseAlg; @@ -342,6 +357,18 @@ public function setDefaults(object|array $values, bool $erase = false): static $values['auth_source'] = null; } + $requestUris = isset($values['request_uris']) && is_array($values['request_uris']) ? + $values['request_uris'] : + []; + $stringUris = []; + /** @var mixed $uri */ + foreach ($requestUris as $uri) { + if (is_string($uri)) { + $stringUris[] = $uri; + } + } + $values['request_uris'] = implode("\n", $stringUris); + $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] = is_array( $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES], ) ? $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] : []; @@ -368,6 +395,7 @@ protected function buildForm(): void $this->onValidate[] = $this->validateFederationJwks(...); $this->onValidate[] = $this->validateProtocolJwks(...); $this->onValidate[] = $this->validateJwksUri(...); + $this->onValidate[] = $this->validateRequestUris(...); $this->setMethod('POST'); $this->addComponent($this->csrfProtection, Form::ProtectorId); @@ -442,6 +470,11 @@ protected function buildForm(): void 3, )->setHtmlAttribute('class', 'full-width') ->setRequired(Translate::noop('At least one response mode is required.')); + + $this->addCheckbox('require_pushed_authorization_requests', 'Require Pushed Authorization Requests (PAR)'); + $this->addCheckbox('require_signed_request_object', 'Require Signed Request Object'); + $this->addTextArea('request_uris', 'Request URIs (OIDC Core / JAR, one per line)', null, 5) + ->setHtmlAttribute('class', 'full-width'); } /** diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 947492c0..11cfd3f4 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -123,6 +123,12 @@ class ModuleConfig final public const string OPTION_VCI_SIGNATURE_KEY_PAIRS = 'vci_signature_key_pairs'; final public const string OPTION_VCI_CREDENTIAL_JSON_LD_CONTEXT = 'vci_credential_json_ld_context'; + final public const string OPTION_PAR_REQUEST_URI_TTL = 'parRequestUriDuration'; + final public const string OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS = 'requirePushedAuthorizationRequests'; + final public const string OPTION_REQUIRE_SIGNED_REQUEST_OBJECT = 'requireSignedRequestObject'; + final public const string OPTION_REQUEST_URI_TIMEOUT = 'requestUriTimeout'; + final public const string OPTION_REQUEST_URI_MAX_SIZE_BYTES = 'requestUriMaxSizeBytes'; + protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ self::KEY_DESCRIPTION => 'openid', @@ -323,6 +329,33 @@ public function getRefreshTokenDuration(): DateInterval ); } + public function getParRequestUriTtl(): DateInterval + { + return new DateInterval( + $this->config()->getOptionalString(self::OPTION_PAR_REQUEST_URI_TTL, 'PT10M'), + ); + } + + public function getRequirePushedAuthorizationRequests(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS, false); + } + + public function getRequireSignedRequestObject(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT, false); + } + + public function getRequestUriTimeout(): int + { + return $this->config()->getOptionalInteger(self::OPTION_REQUEST_URI_TIMEOUT, 5); + } + + public function getRequestUriMaxSizeBytes(): int + { + return $this->config()->getOptionalInteger(self::OPTION_REQUEST_URI_MAX_SIZE_BYTES, 102400); + } + /** * @throws \Exception */ diff --git a/src/Repositories/PushedAuthorizationRequestRepository.php b/src/Repositories/PushedAuthorizationRequestRepository.php new file mode 100644 index 00000000..a1494e4e --- /dev/null +++ b/src/Repositories/PushedAuthorizationRequestRepository.php @@ -0,0 +1,100 @@ +database->applyPrefix(self::TABLE_NAME); + } + + /** + * Persist the PAR entity in the database. + * + * @throws \JsonException + */ + public function persist(PushedAuthorizationRequestEntity $entity): void + { + $state = $entity->getState(); + + $stmt = "INSERT INTO {$this->getTableName()} (request_uri, client_id, parameters, expires_at, is_consumed) " . + "VALUES (:request_uri, :client_id, :parameters, :expires_at, :is_consumed)"; + + $this->database->write($stmt, [ + 'request_uri' => $state['request_uri'], + 'client_id' => $state['client_id'], + 'parameters' => $state['parameters'], + 'expires_at' => $state['expires_at'], + 'is_consumed' => $state['is_consumed'] ? 1 : 0, + ]); + } + + /** + * Find PAR entity by request_uri. + * + * @throws \Exception + */ + public function findByRequestUri(string $requestUri): ?PushedAuthorizationRequestEntity + { + $stmt = $this->database->read( + "SELECT client_id, parameters, expires_at, is_consumed " . + "FROM {$this->getTableName()} WHERE request_uri = :request_uri LIMIT 1", + ['request_uri' => $requestUri], + ); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + + /** @psalm-suppress MixedAssignment */ + $decoded = json_decode((string)$row['parameters'], true, 512, JSON_THROW_ON_ERROR); + $parameters = is_array($decoded) ? $decoded : []; + + return new PushedAuthorizationRequestEntity( + requestUri: $requestUri, + clientId: (string)$row['client_id'], + parameters: $parameters, + expiresAt: new DateTimeImmutable((string)$row['expires_at']), + isConsumed: (bool)$row['is_consumed'], + ); + } + + /** + * Mark a PAR record as consumed. + */ + public function consume(string $requestUri): void + { + $stmt = "UPDATE {$this->getTableName()} SET is_consumed = 1 WHERE request_uri = :request_uri"; + $this->database->write($stmt, ['request_uri' => $requestUri]); + } + + /** + * Delete expired PAR records. + */ + public function deleteExpired(DateTimeImmutable $now): int + { + $stmt = "DELETE FROM {$this->getTableName()} WHERE expires_at < :now"; + $result = $this->database->write($stmt, ['now' => $now->format('Y-m-d H:i:s')]); + return is_int($result) ? $result : 0; + } +} diff --git a/src/Server/AuthorizationServer.php b/src/Server/AuthorizationServer.php index 56e1d7c4..ca3a156e 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -15,6 +15,8 @@ use LogicException; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Error\BadRequest; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\Grants\Interfaces\AuthorizationValidatableWithRequestRules; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; @@ -25,10 +27,15 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; +use SimpleSAML\Module\oidc\Server\RequestTypes\AuthorizationRequest; use SimpleSAML\Module\oidc\Server\RequestTypes\LogoutRequest; use SimpleSAML\Module\oidc\Server\ResponseModes\QueryResponseMode; use SimpleSAML\Module\oidc\Services\LoggerService; +use SimpleSAML\Module\oidc\Utils\JwksResolver; +use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; +use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\Utils\RequestUriFetcher; class AuthorizationServer extends OAuth2AuthorizationServer { @@ -37,6 +44,13 @@ class AuthorizationServer extends OAuth2AuthorizationServer protected RequestRulesManager $requestRulesManager; + protected ?PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository = null; + protected ?RequestParamsResolver $requestParamsResolver = null; + protected ?JwksResolver $jwksResolver = null; + protected ?RequestUriFetcher $requestUriFetcher = null; + protected ?Core $core = null; + protected ?ModuleConfig $moduleConfig = null; + /** * @var \League\OAuth2\Server\CryptKey * @psalm-suppress PropertyNotSetInConstructor @@ -55,6 +69,12 @@ public function __construct( ?ResponseTypeInterface $responseType = null, ?RequestRulesManager $requestRulesManager = null, protected readonly ?LoggerService $loggerService = null, + ?PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository = null, + ?RequestParamsResolver $requestParamsResolver = null, + ?JwksResolver $jwksResolver = null, + ?RequestUriFetcher $requestUriFetcher = null, + ?Core $core = null, + ?ModuleConfig $moduleConfig = null, ) { parent::__construct( $clientRepository, @@ -71,6 +91,13 @@ public function __construct( throw new LogicException('Can not validate request (no RequestRulesManager defined)'); } $this->requestRulesManager = $requestRulesManager; + + $this->pushedAuthorizationRequestRepository = $pushedAuthorizationRequestRepository; + $this->requestParamsResolver = $requestParamsResolver; + $this->jwksResolver = $jwksResolver; + $this->requestUriFetcher = $requestUriFetcher; + $this->core = $core; + $this->moduleConfig = $moduleConfig; } /** @@ -83,14 +110,173 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O { $this->loggerService?->debug('AuthorizationServer::validateAuthorizationRequest'); - $rulesToExecute = [ - StateRule::class, - ClientRule::class, - ClientRedirectUriRule::class, - ResponseModeRule::class, - ]; - try { + $queryParams = $request->getQueryParams(); + $bodyParams = $request->getParsedBody(); + $params = array_merge($queryParams, is_array($bodyParams) ? $bodyParams : []); + + $requestUri = isset($params['request_uri']) && is_string($params['request_uri']) ? + $params['request_uri'] : + null; + $parRequestUri = null; + + if (is_string($requestUri) && $requestUri !== '') { + if (str_starts_with($requestUri, 'urn:ietf:params:oauth:request_uri:')) { + if ($this->pushedAuthorizationRequestRepository === null) { + throw new LogicException('PushedAuthorizationRequestRepository is not configured.'); + } + $parEntity = $this->pushedAuthorizationRequestRepository->findByRequestUri($requestUri); + if ($parEntity === null) { + throw OidcServerException::invalidRequest( + 'request_uri', + 'Pushed authorization request not found or expired.', + ); + } + if ($parEntity->isConsumed()) { + throw OidcServerException::invalidRequest( + 'request_uri', + 'Pushed authorization request has already been consumed.', + ); + } + + $clientId = isset($params['client_id']) && is_string($params['client_id']) ? + $params['client_id'] : + null; + if ($clientId !== null && $clientId !== $parEntity->getClientId()) { + throw OidcServerException::invalidRequest( + 'client_id', + 'Client ID does not match the pushed authorization request client ID.', + ); + } + + $request = $request->withQueryParams($parEntity->getParameters())->withParsedBody([]); + $parRequestUri = $requestUri; + } elseif (str_starts_with(strtolower($requestUri), 'https://')) { + if ( + $this->requestParamsResolver === null || + $this->jwksResolver === null || + $this->requestUriFetcher === null || + $this->core === null || + $this->moduleConfig === null + ) { + throw new LogicException( + 'Required dependencies for JAR request_uri fetching are not configured.', + ); + } + + $clientId = isset($params['client_id']) && is_string($params['client_id']) ? + $params['client_id'] : + null; + if (empty($clientId)) { + throw OidcServerException::invalidRequest( + 'client_id', + 'Client ID is required when using request_uri.', + ); + } + + + + $client = $this->clientRepository->getClientEntity($clientId); + if ($client === null) { + throw OidcServerException::invalidRequest('client_id', 'Client not found.'); + } + + if (!$client instanceof \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface) { + throw OidcServerException::invalidRequest( + 'request_uri', + 'Client is not supported.', + ); + } + + $allowedRequestUris = $client->getRequestUris(); + if (!in_array($requestUri, $allowedRequestUris, true)) { + throw OidcServerException::invalidRequest( + 'request_uri', + 'The request_uri is not registered for this client.', + ); + } + + try { + $jwtString = $this->requestUriFetcher->fetch( + $requestUri, + $this->moduleConfig->getRequestUriTimeout(), + $this->moduleConfig->getRequestUriMaxSizeBytes(), + ); + } catch (\Throwable $t) { + throw OidcServerException::invalidRequest( + 'request_uri', + 'Failed to fetch request_uri: ' . $t->getMessage(), + ); + } + + try { + $requestObject = $this->core->jarRequestObjectFactory()->fromToken($jwtString); + $jwks = $this->jwksResolver->forClient($client); + if ($jwks === null) { + throw new \Exception('Client JWKS not available.'); + } + $requestObject->verifyWithKeySet($jwks); + + if ($requestObject->getClientId() !== $client->getIdentifier()) { + throw new \Exception('client_id claim in request object does not match.'); + } + + $jwtPayload = $requestObject->getPayload(); + $mergedParams = array_merge($params, $jwtPayload); + unset($mergedParams['request_uri']); + unset($mergedParams['request']); + + $request = $request->withQueryParams($mergedParams)->withParsedBody([]); + } catch (\Throwable $t) { + throw OidcServerException::invalidRequest( + 'request_uri', + 'Invalid Request Object at request_uri: ' . $t->getMessage(), + ); + } + } else { + throw OidcServerException::invalidRequest( + 'request_uri', + 'Invalid request_uri scheme/format.', + ); + } + } + + // Check if PAR is required + $currentQueryParams = $request->getQueryParams(); + $currentBodyParams = $request->getParsedBody(); + $currentParams = array_merge( + $currentQueryParams, + is_array($currentBodyParams) ? $currentBodyParams : [], + ); + $resolvedClientId = isset($currentParams['client_id']) && is_string($currentParams['client_id']) ? + $currentParams['client_id'] : + null; + + $parRequired = $this->moduleConfig?->getRequirePushedAuthorizationRequests() ?? false; + + if (is_string($resolvedClientId) && $resolvedClientId !== '') { + $resolvedClient = $this->clientRepository->getClientEntity($resolvedClientId); + if ($resolvedClient instanceof \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface) { + if ($resolvedClient->getRequirePushedAuthorizationRequests()) { + $parRequired = true; + } + } + } + + if ($parRequired && $parRequestUri === null) { + throw OidcServerException::invalidRequest( + 'request_uri', + 'Pushed Authorization Request (PAR) is required.', + ); + } + + $rulesToExecute = [ + StateRule::class, + ClientRule::class, + ClientRedirectUriRule::class, + ResponseModeRule::class, + ]; + $resultBag = $this->requestRulesManager->check( $request, $rulesToExecute, @@ -148,7 +334,11 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O ), ); - return $grantType->validateAuthorizationRequestWithRequestRules($request, $resultBag); + $authorizationRequest = $grantType->validateAuthorizationRequestWithRequestRules($request, $resultBag); + if ($authorizationRequest instanceof AuthorizationRequest && isset($parRequestUri)) { + $authorizationRequest->setParRequestUri($parRequestUri); + } + return $authorizationRequest; } else { $this->loggerService?->debug( 'AuthorizationServer: Grant type can NOT respond to ' . diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index ff62f578..a6aaba28 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -36,6 +36,7 @@ use SimpleSAML\Module\oidc\Repositories\Interfaces\AccessTokenRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\AuthCodeRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\RefreshTokenRepositoryInterface; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\Grants\Interfaces\AuthorizationValidatableWithRequestRules; use SimpleSAML\Module\oidc\Server\Grants\Interfaces\OidcCapableGrantTypeInterface; @@ -179,6 +180,7 @@ public function __construct( protected RefreshTokenIssuer $refreshTokenIssuer, protected Helpers $helpers, protected LoggerService $loggerService, + protected PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, ) { parent::__construct($authCodeRepository, $refreshTokenRepository, $authCodeTTL); @@ -305,6 +307,11 @@ public function completeOidcAuthorizationRequest( // parameter. Use storage instead. ]; + $parRequestUri = $authorizationRequest->getParRequestUri(); + if ($parRequestUri !== null) { + $this->pushedAuthorizationRequestRepository->consume($parRequestUri); + } + $jsonPayload = json_encode($payload, JSON_THROW_ON_ERROR); $responseMode = $authorizationRequest->getResponseMode() ?? new QueryResponseMode(); diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index e4eab6e3..e29c48b0 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -6,6 +6,7 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultInterface; @@ -24,6 +25,7 @@ public function __construct( RequestParamsResolver $requestParamsResolver, Helpers $helpers, protected JwksResolver $jwksResolver, + protected ModuleConfig $moduleConfig, ) { parent::__construct($requestParamsResolver, $helpers); } @@ -66,13 +68,7 @@ public function checkRule( // There is no request object already resolved. We will do it now. $requestObject = $this->requestParamsResolver->parseRequestObjectToken($requestParam); - // If request object is not protected (signed), we are allowed to use it as is. - if (!$requestObject->isProtected()) { - return new Result($this->getKey(), $requestObject->getPayload()); - } - - // It is protected, we must validate it. - + // If request object is not protected (signed), check if signature is required. /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); /** @var string $redirectUri */ @@ -80,6 +76,24 @@ public function checkRule( /** @var ?string $stateValue */ $stateValue = ($currentResultBag->get(StateRule::class))?->getValue(); + if (!$requestObject->isProtected()) { + $requireSigned = $this->moduleConfig->getRequireSignedRequestObject() || + $client->getRequireSignedRequestObject(); + if ($requireSigned) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object must be signed (alg: none is not allowed).', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + return new Result($this->getKey(), $requestObject->getPayload()); + } + + // It is protected, we must validate it. + ($jwks = $this->jwksResolver->forClient($client)) || throw OidcServerException::accessDenied( 'can not validate request object, client JWKS not available', $redirectUri, diff --git a/src/Server/RequestTypes/AuthorizationRequest.php b/src/Server/RequestTypes/AuthorizationRequest.php index bc969f10..c392d36d 100644 --- a/src/Server/RequestTypes/AuthorizationRequest.php +++ b/src/Server/RequestTypes/AuthorizationRequest.php @@ -73,6 +73,8 @@ class AuthorizationRequest extends OAuth2AuthorizationRequest private ?ResponseModeInterface $responseMode = null; + protected ?string $parRequestUri = null; + public static function fromOAuth2AuthorizationRequest( OAuth2AuthorizationRequest $oAuth2authorizationRequest, ): AuthorizationRequest { @@ -303,4 +305,14 @@ public function setBoundRedirectUri(?string $boundRedirectUri): void { $this->boundRedirectUri = $boundRedirectUri; } + + public function getParRequestUri(): ?string + { + return $this->parRequestUri; + } + + public function setParRequestUri(?string $parRequestUri): void + { + $this->parRequestUri = $parRequestUri; + } } diff --git a/src/Services/Container.php b/src/Services/Container.php index 8f418899..31d4cb2a 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -67,6 +67,7 @@ use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\CodeChallengeVerifiersRepository; use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Repositories\ScopeRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; @@ -122,6 +123,7 @@ use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Jwks; use SimpleSAML\OpenID\Jws; +use SimpleSAML\OpenID\Utils\RequestUriFetcher; use SimpleSAML\Session; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -282,6 +284,16 @@ public function __construct() ); $this->services[ClientRepository::class] = $clientRepository; + $pushedAuthorizationRequestRepository = new PushedAuthorizationRequestRepository( + $moduleConfig, + $database, + $protocolCache, + ); + $this->services[PushedAuthorizationRequestRepository::class] = $pushedAuthorizationRequestRepository; + + $requestUriFetcher = $core->requestUriFetcher(); + $this->services[RequestUriFetcher::class] = $requestUriFetcher; + $userEntityFactory = new UserEntityFactory($helpers); $this->services[UserEntityFactory::class] = $userEntityFactory; @@ -447,7 +459,7 @@ public function __construct() $federationCache, ), new ClientRedirectUriRule($requestParamsResolver, $helpers, $moduleConfig), - new RequestObjectRule($requestParamsResolver, $helpers, $jwksResolver), + new RequestObjectRule($requestParamsResolver, $helpers, $jwksResolver, $moduleConfig), new ResponseModeRule( $requestParamsResolver, $helpers, @@ -534,6 +546,7 @@ public function __construct() $refreshTokenIssuer, $helpers, $loggerService, + $pushedAuthorizationRequestRepository, ); $this->services[AuthCodeGrant::class] = $authCodeGrantFactory->build(); @@ -567,6 +580,7 @@ public function __construct() $refreshTokenIssuer, $helpers, $loggerService, + $pushedAuthorizationRequestRepository, ); $this->services[PreAuthCodeGrant::class] = $preAuthCodeGrantFactory->build(); @@ -583,6 +597,11 @@ public function __construct() $privateKey, $this->services[PreAuthCodeGrant::class], $loggerService, + $pushedAuthorizationRequestRepository, + $requestParamsResolver, + $jwksResolver, + $requestUriFetcher, + $core, ); $this->services[AuthorizationServer::class] = $authorizationServerFactory->build(); diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 2eadd226..291030c7 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -219,6 +219,11 @@ public function migrate(): void $this->version20260218163000(); $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260218163000')"); } + + if (!in_array('20260608130000', $versions, true)) { + $this->version20260608130000(); + $this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260608130000')"); + } } private function versionsTableName(): string @@ -739,6 +744,30 @@ private function version20260218163000(): void ,); } + + private function version20260608130000(): void + { + $parTableName = $this->database->applyPrefix('oidc_par'); + $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); + $fkParClient = $this->generateIdentifierName([$parTableName, 'client_id'], 'fk'); + + $this->database->write(<<< EOT + CREATE TABLE $parTableName ( + request_uri VARCHAR(191) PRIMARY KEY NOT NULL, + client_id VARCHAR(191) NOT NULL, + parameters TEXT NOT NULL, + expires_at TIMESTAMP NOT NULL, + is_consumed BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT $fkParClient FOREIGN KEY (client_id) + REFERENCES $clientTableName (id) ON DELETE CASCADE + ) +EOT + ,); + + $this->database->write("CREATE INDEX oidc_par_expires_at_idx ON $parTableName (expires_at)"); + } + + /** * @param string[] $columnNames */ diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index df958b03..36c585da 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -77,7 +77,11 @@ private function initMetadata(): void 'none', ...$supportedSignatureAlgorithmNames, ]; - $this->metadata[ClaimsEnum::RequestUriParameterSupported->value] = false; + $this->metadata[ClaimsEnum::RequestUriParameterSupported->value] = true; + $this->metadata[ClaimsEnum::PushedAuthorizationRequestEndpoint->value] = + $this->moduleConfig->getModuleUrl(RoutesEnum::PushedAuthorizationRequest->value); + $this->metadata[ClaimsEnum::RequirePushedAuthorizationRequests->value] = + $this->moduleConfig->getRequirePushedAuthorizationRequests(); $grantTypesSupported = [ GrantTypesEnum::AuthorizationCode->value, diff --git a/src/Utils/Routes.php b/src/Utils/Routes.php index 68791e1e..667a5e1c 100644 --- a/src/Utils/Routes.php +++ b/src/Utils/Routes.php @@ -218,6 +218,12 @@ public function urlFederationList(array $parameters = []): string return $this->getModuleUrl(RoutesEnum::FederationList->value, $parameters); } + + public function urlPushedAuthorizationRequest(array $parameters = []): string + { + return $this->getModuleUrl(RoutesEnum::PushedAuthorizationRequest->value, $parameters); + } + /***************************************************************************************************************** * OpenID for Verifiable Credential Issuance URLs. ****************************************************************************************************************/ diff --git a/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php new file mode 100644 index 00000000..4e29c812 --- /dev/null +++ b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php @@ -0,0 +1,174 @@ +authenticatedOAuth2ClientResolverMock = $this->createMock(AuthenticatedOAuth2ClientResolver::class); + $this->pushedAuthorizationRequestRepositoryMock = $this->createMock( + PushedAuthorizationRequestRepository::class, + ); + $this->requestRulesManagerMock = $this->createMock(RequestRulesManager::class); + $this->jwksResolverMock = $this->createMock(JwksResolver::class); + $this->coreMock = $this->createMock(Core::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); + $this->errorResponderMock = $this->createMock(ErrorResponder::class); + $this->helpers = new Helpers(); + $this->loggerMock = $this->createMock(LoggerService::class); + + $this->serverRequestMock = $this->createMock(ServerRequestInterface::class); + $this->responseMock = $this->createMock(ResponseInterface::class); + $this->responseFactoryMock = $this->createMock(ResponseFactoryInterface::class); + $this->streamMock = $this->createMock(StreamInterface::class); + + $this->responseMock->method('getBody')->willReturn($this->streamMock); + $this->responseMock->method('withStatus')->willReturn($this->responseMock); + $this->responseMock->method('withHeader')->willReturn($this->responseMock); + $this->responseFactoryMock->method('createResponse')->willReturn($this->responseMock); + $this->psrHttpBridgeMock->method('getResponseFactory')->willReturn($this->responseFactoryMock); + } + + protected function sut(): PushedAuthorizationController + { + return new PushedAuthorizationController( + $this->authenticatedOAuth2ClientResolverMock, + $this->pushedAuthorizationRequestRepositoryMock, + $this->requestRulesManagerMock, + $this->jwksResolverMock, + $this->coreMock, + $this->moduleConfigMock, + $this->psrHttpBridgeMock, + $this->errorResponderMock, + $this->helpers, + $this->loggerMock, + ); + } + + public function testItIsInitializable(): void + { + $this->assertInstanceOf(PushedAuthorizationController::class, $this->sut()); + } + + public function testMethodMustBePost(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('GET'); + + $this->responseMock->expects($this->once())->method('withStatus') + ->with(405)->willReturn($this->responseMock); + $this->responseMock->expects($this->once())->method('withHeader') + ->with('Allow', 'POST')->willReturn($this->responseMock); + + $response = $this->sut()->__invoke($this->serverRequestMock); + $this->assertSame($this->responseMock, $response); + } + + public function testClientAuthenticationFailureThrows(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod')->willReturn(null); + + $this->expectException(OidcServerException::class); + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testRejectsRequestUriInBody(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + + $clientMock = $this->createMock(ClientEntityInterface::class); + $resolvedAuth = new ResolvedClientAuthenticationMethod( + $clientMock, + ClientAuthenticationMethodsEnum::ClientSecretPost, + ); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willReturn($resolvedAuth); + + $this->serverRequestMock->method('getParsedBody')->willReturn([ + 'request_uri' => 'some-uri', + ]); + + $this->expectException(OidcServerException::class); + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testHandlesValidParRequest(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + + $clientMock = $this->createMock(ClientEntityInterface::class); + $clientMock->method('getIdentifier')->willReturn('client123'); + + $resolvedAuth = new ResolvedClientAuthenticationMethod( + $clientMock, + ClientAuthenticationMethodsEnum::ClientSecretPost, + ); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod')->willReturn($resolvedAuth); + + $params = [ + 'redirect_uri' => 'https://localhost/callback', + 'response_type' => 'code', + 'scope' => 'openid', + 'state' => 'xyz', + ]; + $this->serverRequestMock->method('getParsedBody')->willReturn($params); + + $this->serverRequestMock->method('withParsedBody')->willReturn($this->serverRequestMock); + $this->serverRequestMock->method('withQueryParams')->willReturn($this->serverRequestMock); + + $this->moduleConfigMock->method('getParRequestUriTtl')->willReturn(new \DateInterval('PT10M')); + + $this->pushedAuthorizationRequestRepositoryMock->expects($this->once())->method('persist'); + + $this->responseMock->expects($this->once())->method('withStatus') + ->with(201)->willReturn($this->responseMock); + + $response = $this->sut()->__invoke($this->serverRequestMock); + $this->assertSame($this->responseMock, $response); + } +} diff --git a/tests/unit/src/Entities/ClientEntityTest.php b/tests/unit/src/Entities/ClientEntityTest.php index acca0582..5e82b3f1 100644 --- a/tests/unit/src/Entities/ClientEntityTest.php +++ b/tests/unit/src/Entities/ClientEntityTest.php @@ -221,6 +221,9 @@ public function testCanExportAsArray(): void 'expires_at' => null, 'is_generic' => false, 'id_token_signed_response_alg' => null, + 'require_pushed_authorization_requests' => false, + 'require_signed_request_object' => false, + 'request_uris' => [], ], ); } diff --git a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php index d991c6a1..52a187d1 100644 --- a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php +++ b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php @@ -10,10 +10,10 @@ use SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Helpers; -use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\Interfaces\AccessTokenRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\AuthCodeRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\RefreshTokenRepositoryInterface; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; @@ -30,7 +30,7 @@ class AuthCodeGrantTest extends TestCase protected Stub $refreshTokenRepositoryStub; protected DateInterval $authCodeTtl; protected Stub $requestRulesManagerStub; - protected Stub $moduleConfigStub; + protected Stub $pushedAuthorizationRequestRepositoryStub; protected Stub $requestParamsResolverStub; protected Stub $accessTokenEntityFactoryStub; protected Stub $authCodeEntityFactoryStub; @@ -48,7 +48,9 @@ protected function setUp(): void $this->refreshTokenRepositoryStub = $this->createStub(RefreshTokenRepositoryInterface::class); $this->authCodeTtl = new DateInterval('PT1M'); $this->requestRulesManagerStub = $this->createStub(RequestRulesManager::class); - $this->moduleConfigStub = $this->createStub(ModuleConfig::class); + $this->pushedAuthorizationRequestRepositoryStub = $this->createStub( + PushedAuthorizationRequestRepository::class, + ); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->accessTokenEntityFactoryStub = $this->createStub(AccessTokenEntityFactory::class); $this->authCodeEntityFactoryStub = $this->createStub(AuthcodeEntityFactory::class); @@ -76,7 +78,7 @@ public function testCanCreateInstance(): void $this->refreshTokenIssuerStub, $this->helpersStub, $this->loggerMock, - $this->moduleConfigStub, + $this->pushedAuthorizationRequestRepositoryStub, ), ); } diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php index 56f0679a..f044b826 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php @@ -11,6 +11,7 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; @@ -26,7 +27,7 @@ #[CoversClass(RequestObjectRule::class)] class RequestObjectRuleTest extends TestCase { - protected Stub $clientStub; + protected MockObject $clientStub; protected Stub $resultBagStub; protected MockObject $requestParamsResolverMock; protected MockObject $requestObjectMock; @@ -35,10 +36,11 @@ class RequestObjectRuleTest extends TestCase protected MockObject $jwksResolverMock; protected Helpers $helpers; protected Stub $responseModeStub; + protected Stub $moduleConfigStub; protected function setUp(): void { - $this->clientStub = $this->createStub(ClientEntityInterface::class); + $this->clientStub = $this->createMock(ClientEntityInterface::class); $this->resultBagStub = $this->createStub(ResultBag::class); $this->resultBagStub->method('getOrFail')->willReturnMap([ [ClientRule::class, new Result(ClientRule::class, $this->clientStub)], @@ -52,21 +54,25 @@ protected function setUp(): void $this->jwksResolverMock = $this->createMock(JwksResolver::class); $this->helpers = new Helpers(); $this->responseModeStub = $this->createStub(ResponseModeInterface::class); + $this->moduleConfigStub = $this->createStub(ModuleConfig::class); } protected function sut( ?RequestParamsResolver $requestParamsResolver = null, ?Helpers $helpers = null, ?JwksResolver $jwksResolver = null, + ?ModuleConfig $moduleConfig = null, ): RequestObjectRule { $requestParamsResolver ??= $this->requestParamsResolverMock; $helpers ??= $this->helpers; $jwksResolver ??= $this->jwksResolverMock; + $moduleConfig ??= $this->moduleConfigStub; return new RequestObjectRule( $requestParamsResolver, $helpers, $jwksResolver, + $moduleConfig, ); } @@ -112,7 +118,8 @@ public function testMissingClientJwksThrows(): void $this->requestObjectMock->method('isProtected')->willReturn(true); $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') ->with('token')->willReturn($this->requestObjectMock); - $this->clientStub->expects($this->once())->method('getJwks')->willReturn(null); + $this->jwksResolverMock->expects($this->once())->method('forClient') + ->with($this->clientStub)->willReturn(null); $this->expectException(OidcServerException::class); $this->sut()->checkRule( @@ -171,4 +178,45 @@ public function testReturnsValidRequestObject(): void $this->assertIsArray($result->getValue()); $this->assertNotEmpty($result->getValue()); } + + public function testThrowsWhenGlobalRequireSignedRequestObjectIsEnabled(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->requestObjectMock->method('isProtected')->willReturn(false); + $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') + ->with('token')->willReturn($this->requestObjectMock); + + $this->moduleConfigStub->method('getRequireSignedRequestObject')->willReturn(true); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testThrowsWhenClientRequireSignedRequestObjectIsEnabled(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->requestObjectMock->method('isProtected')->willReturn(false); + $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') + ->with('token')->willReturn($this->requestObjectMock); + + $this->moduleConfigStub->method('getRequireSignedRequestObject')->willReturn(false); + $this->clientStub->method('getRequireSignedRequestObject')->willReturn(true); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } } diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index e6024104..68eba186 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -48,6 +48,7 @@ public function setUp(): void RoutesEnum::UserInfo->value => 'http://localhost/userinfo', RoutesEnum::Jwks->value => 'http://localhost/jwks', RoutesEnum::EndSession->value => 'http://localhost/end-session', + RoutesEnum::PushedAuthorizationRequest->value => 'http://localhost/par', ]; return $paths[$path] ?? null; @@ -137,7 +138,9 @@ public function testItReturnsExpectedMetadata(): void ], 'request_parameter_supported' => true, 'request_object_signing_alg_values_supported' => ['none', 'RS256'], - 'request_uri_parameter_supported' => false, + 'request_uri_parameter_supported' => true, + 'pushed_authorization_request_endpoint' => 'http://localhost/par', + 'require_pushed_authorization_requests' => false, 'grant_types_supported' => ['authorization_code', 'refresh_token'], 'claims_parameter_supported' => true, 'acr_values_supported' => ['1'], From 8519e54fe68269e4c9df5cfc036371f50c308457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 11 Jun 2026 10:20:52 +0200 Subject: [PATCH 02/11] WIP --- composer.json | 2 +- psalm.xml | 1 + routing/services/services.yml | 4 +++ .../PushedAuthorizationController.php | 6 ++-- src/Factories/AuthorizationServerFactory.php | 9 ++---- src/Factories/CoreFactory.php | 6 ++-- src/Factories/JarFactory.php | 31 ++++++++++++++++++ src/Factories/RequestObjectFactory.php | 32 +++++++++++++++++++ src/Server/AuthorizationServer.php | 19 ++++------- src/Server/RequestRules/Rules/ScopeRule.php | 3 +- src/Services/Container.php | 11 +++---- .../PushedAuthorizationControllerTest.php | 8 ++--- 12 files changed, 96 insertions(+), 36 deletions(-) create mode 100644 src/Factories/JarFactory.php create mode 100644 src/Factories/RequestObjectFactory.php diff --git a/composer.json b/composer.json index 91ed0ae0..777f7816 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "psr/container": "^2.0", "psr/log": "^3", "simplesamlphp/composer-module-installer": "^1.3", - "simplesamlphp/openid": "~0.2.3", + "simplesamlphp/openid": "~v0.3.0", "spomky-labs/base64url": "^2.0", "symfony/expression-language": "^7.4", "symfony/psr-http-message-bridge": "^7.4", diff --git a/psalm.xml b/psalm.xml index ce0e8ea4..83fa4ca0 100644 --- a/psalm.xml +++ b/psalm.xml @@ -50,6 +50,7 @@ + diff --git a/routing/services/services.yml b/routing/services/services.yml index ffcad634..8a7a0de9 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -134,6 +134,10 @@ services: SimpleSAML\OpenID\Did: ~ SimpleSAML\OpenID\Jws: factory: [ '@SimpleSAML\Module\oidc\Factories\JwsFactory', 'build' ] + SimpleSAML\OpenID\Jar: + factory: [ '@SimpleSAML\Module\oidc\Factories\JarFactory', 'build' ] + SimpleSAML\OpenID\RequestObject: + factory: [ '@SimpleSAML\Module\oidc\Factories\RequestObjectFactory', 'build' ] # SSP diff --git a/src/Controllers/PushedAuthorizationController.php b/src/Controllers/PushedAuthorizationController.php index 527cc745..1542f587 100644 --- a/src/Controllers/PushedAuthorizationController.php +++ b/src/Controllers/PushedAuthorizationController.php @@ -39,7 +39,7 @@ use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; -use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\RequestObject; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -50,7 +50,7 @@ public function __construct( private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, private readonly RequestRulesManager $requestRulesManager, private readonly JwksResolver $jwksResolver, - private readonly Core $core, + private readonly RequestObject $requestObject, private readonly ModuleConfig $moduleConfig, private readonly PsrHttpBridge $psrHttpBridge, private readonly ErrorResponder $errorResponder, @@ -96,7 +96,7 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface // 4. Handle JAR in PAR (request parameter) if (isset($params['request'])) { try { - $requestObject = $this->core->jarRequestObjectFactory()->fromToken((string)$params['request']); + $requestObject = $this->requestObject->jarRequestObjectFactory()->fromToken((string)$params['request']); $jwks = $this->jwksResolver->forClient($client); if (is_null($jwks)) { throw OidcServerException::invalidRequest( diff --git a/src/Factories/AuthorizationServerFactory.php b/src/Factories/AuthorizationServerFactory.php index 90cb5a7e..fffa5513 100644 --- a/src/Factories/AuthorizationServerFactory.php +++ b/src/Factories/AuthorizationServerFactory.php @@ -32,8 +32,7 @@ use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; -use SimpleSAML\OpenID\Core; -use SimpleSAML\OpenID\Utils\RequestUriFetcher; +use SimpleSAML\OpenID\RequestObject; class AuthorizationServerFactory { @@ -53,8 +52,7 @@ public function __construct( private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, private readonly RequestParamsResolver $requestParamsResolver, private readonly JwksResolver $jwksResolver, - private readonly RequestUriFetcher $requestUriFetcher, - private readonly Core $core, + private readonly RequestObject $requestObject, ) { } @@ -72,8 +70,7 @@ public function build(): AuthorizationServer $this->pushedAuthorizationRequestRepository, $this->requestParamsResolver, $this->jwksResolver, - $this->requestUriFetcher, - $this->core, + $this->requestObject, $this->moduleConfig, ); diff --git a/src/Factories/CoreFactory.php b/src/Factories/CoreFactory.php index dba9cd26..ef454c8b 100644 --- a/src/Factories/CoreFactory.php +++ b/src/Factories/CoreFactory.php @@ -17,13 +17,15 @@ public function __construct( } /** - * @throws \ReflectionException - * @throws \SimpleSAML\Error\ConfigurationError + * Builds a new Core instance. + * + * @return Core */ public function build(): Core { return new Core( supportedAlgorithms: $this->moduleConfig->getSupportedAlgorithms(), + timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), logger: $this->loggerService, ); } diff --git a/src/Factories/JarFactory.php b/src/Factories/JarFactory.php new file mode 100644 index 00000000..d8864eea --- /dev/null +++ b/src/Factories/JarFactory.php @@ -0,0 +1,31 @@ +moduleConfig->getSupportedAlgorithms(), + timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), + ); + } +} diff --git a/src/Factories/RequestObjectFactory.php b/src/Factories/RequestObjectFactory.php new file mode 100644 index 00000000..65fd09a5 --- /dev/null +++ b/src/Factories/RequestObjectFactory.php @@ -0,0 +1,32 @@ +moduleConfig->getSupportedAlgorithms(), + timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), + logger: $this->loggerService, + ); + } +} diff --git a/src/Server/AuthorizationServer.php b/src/Server/AuthorizationServer.php index ca3a156e..8ad4218a 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -34,8 +34,7 @@ use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; -use SimpleSAML\OpenID\Core; -use SimpleSAML\OpenID\Utils\RequestUriFetcher; +use SimpleSAML\OpenID\RequestObject; class AuthorizationServer extends OAuth2AuthorizationServer { @@ -47,8 +46,7 @@ class AuthorizationServer extends OAuth2AuthorizationServer protected ?PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository = null; protected ?RequestParamsResolver $requestParamsResolver = null; protected ?JwksResolver $jwksResolver = null; - protected ?RequestUriFetcher $requestUriFetcher = null; - protected ?Core $core = null; + protected ?RequestObject $requestObject = null; protected ?ModuleConfig $moduleConfig = null; /** @@ -72,8 +70,7 @@ public function __construct( ?PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository = null, ?RequestParamsResolver $requestParamsResolver = null, ?JwksResolver $jwksResolver = null, - ?RequestUriFetcher $requestUriFetcher = null, - ?Core $core = null, + ?RequestObject $requestObject = null, ?ModuleConfig $moduleConfig = null, ) { parent::__construct( @@ -95,8 +92,7 @@ public function __construct( $this->pushedAuthorizationRequestRepository = $pushedAuthorizationRequestRepository; $this->requestParamsResolver = $requestParamsResolver; $this->jwksResolver = $jwksResolver; - $this->requestUriFetcher = $requestUriFetcher; - $this->core = $core; + $this->requestObject = $requestObject; $this->moduleConfig = $moduleConfig; } @@ -155,8 +151,7 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O if ( $this->requestParamsResolver === null || $this->jwksResolver === null || - $this->requestUriFetcher === null || - $this->core === null || + $this->requestObject === null || $this->moduleConfig === null ) { throw new LogicException( @@ -197,7 +192,7 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O } try { - $jwtString = $this->requestUriFetcher->fetch( + $jwtString = $this->requestObject->requestUriFetcher()->fetch( $requestUri, $this->moduleConfig->getRequestUriTimeout(), $this->moduleConfig->getRequestUriMaxSizeBytes(), @@ -210,7 +205,7 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O } try { - $requestObject = $this->core->jarRequestObjectFactory()->fromToken($jwtString); + $requestObject = $this->requestObject->jarRequestObjectFactory()->fromToken($jwtString); $jwks = $this->jwksResolver->forClient($client); if ($jwks === null) { throw new \Exception('Client JWKS not available.'); diff --git a/src/Server/RequestRules/Rules/ScopeRule.php b/src/Server/RequestRules/Rules/ScopeRule.php index 2587aaee..3bdb9723 100644 --- a/src/Server/RequestRules/Rules/ScopeRule.php +++ b/src/Server/RequestRules/Rules/ScopeRule.php @@ -56,8 +56,7 @@ public function checkRule( /** @var non-empty-string $scopeDelimiterString */ $scopeDelimiterString = $data['scope_delimiter_string'] ?? ' '; - $loggerService->debug('ScopeRule: defaultScope: ' . ($defaultScope ? $defaultScope : 'N/A')); - ; + $loggerService->debug('ScopeRule: defaultScope: ' . ($defaultScope ?: 'N/A')); $scopeParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( ParamsEnum::Scope->value, diff --git a/src/Services/Container.php b/src/Services/Container.php index 31d4cb2a..b2e26c22 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -123,7 +123,7 @@ use SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Jwks; use SimpleSAML\OpenID\Jws; -use SimpleSAML\OpenID\Utils\RequestUriFetcher; +use SimpleSAML\OpenID\RequestObject; use SimpleSAML\Session; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -291,9 +291,6 @@ public function __construct() ); $this->services[PushedAuthorizationRequestRepository::class] = $pushedAuthorizationRequestRepository; - $requestUriFetcher = $core->requestUriFetcher(); - $this->services[RequestUriFetcher::class] = $requestUriFetcher; - $userEntityFactory = new UserEntityFactory($helpers); $this->services[UserEntityFactory::class] = $userEntityFactory; @@ -584,6 +581,9 @@ public function __construct() ); $this->services[PreAuthCodeGrant::class] = $preAuthCodeGrantFactory->build(); + $requestObject = new RequestObject(); + $this->services[RequestObject::class] = $requestObject; + $authorizationServerFactory = new AuthorizationServerFactory( $moduleConfig, $clientRepository, @@ -600,8 +600,7 @@ public function __construct() $pushedAuthorizationRequestRepository, $requestParamsResolver, $jwksResolver, - $requestUriFetcher, - $core, + $requestObject, ); $this->services[AuthorizationServer::class] = $authorizationServerFactory->build(); diff --git a/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php index 4e29c812..d983a0de 100644 --- a/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php +++ b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php @@ -24,7 +24,7 @@ use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\ValueAbstracts\ResolvedClientAuthenticationMethod; use SimpleSAML\OpenID\Codebooks\ClientAuthenticationMethodsEnum; -use SimpleSAML\OpenID\Core; +use SimpleSAML\OpenID\RequestObject; /** * @covers \SimpleSAML\Module\oidc\Controllers\PushedAuthorizationController @@ -35,7 +35,7 @@ class PushedAuthorizationControllerTest extends TestCase protected MockObject $pushedAuthorizationRequestRepositoryMock; protected MockObject $requestRulesManagerMock; protected MockObject $jwksResolverMock; - protected MockObject $coreMock; + protected MockObject $requestObjectMock; protected MockObject $moduleConfigMock; protected MockObject $psrHttpBridgeMock; protected MockObject $errorResponderMock; @@ -55,7 +55,7 @@ protected function setUp(): void ); $this->requestRulesManagerMock = $this->createMock(RequestRulesManager::class); $this->jwksResolverMock = $this->createMock(JwksResolver::class); - $this->coreMock = $this->createMock(Core::class); + $this->requestObjectMock = $this->createMock(RequestObject::class); $this->moduleConfigMock = $this->createMock(ModuleConfig::class); $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); $this->errorResponderMock = $this->createMock(ErrorResponder::class); @@ -81,7 +81,7 @@ protected function sut(): PushedAuthorizationController $this->pushedAuthorizationRequestRepositoryMock, $this->requestRulesManagerMock, $this->jwksResolverMock, - $this->coreMock, + $this->requestObjectMock, $this->moduleConfigMock, $this->psrHttpBridgeMock, $this->errorResponderMock, From 54c8fc4cfb72edcbd5eebbdc7cc9d26216cd5d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 11 Jun 2026 15:16:02 +0200 Subject: [PATCH 03/11] WIP --- hooks/hook_cron.php | 6 +- psalm.xml | 1 - routing/services/services.yml | 2 - src/Controllers/Admin/ClientController.php | 12 +- .../PushedAuthorizationController.php | 191 +++++---- src/Entities/ClientEntity.php | 12 +- .../PushedAuthorizationRequestEntity.php | 7 +- src/Factories/AuthorizationServerFactory.php | 13 - ...ushedAuthorizationRequestEntityFactory.php | 84 ++++ src/Factories/Grant/AuthCodeGrantFactory.php | 3 - .../Grant/PreAuthCodeGrantFactory.php | 3 - src/Factories/JarFactory.php | 31 -- src/Factories/RequestRulesManagerFactory.php | 10 + src/Forms/ClientForm.php | 26 +- .../PushedAuthorizationRequestRepository.php | 104 +++-- src/Server/AuthorizationServer.php | 203 +--------- src/Server/Grants/AuthCodeGrant.php | 7 - .../RequestRules/Rules/AbstractRule.php | 24 ++ .../RequestRules/Rules/RequestObjectRule.php | 72 +++- .../RequestRules/Rules/RequestUriRule.php | 331 ++++++++++++++++ .../RequestTypes/AuthorizationRequest.php | 12 - src/Services/Container.php | 46 ++- src/Services/DatabaseMigration.php | 6 +- src/Services/ErrorResponder.php | 23 ++ src/Services/OpMetadataService.php | 2 + .../AuthenticatedOAuth2ClientResolver.php | 3 + src/Utils/RequestParamsResolver.php | 161 +++++++- .../PushedAuthorizationControllerTest.php | 215 ++++++++-- ...dAuthorizationRequestEntityFactoryTest.php | 119 ++++++ ...shedAuthorizationRequestRepositoryTest.php | 169 ++++++++ .../src/Server/Grants/AuthCodeGrantTest.php | 10 +- .../Rules/RequestObjectRuleTest.php | 117 +++++- .../RequestRules/Rules/RequestUriRuleTest.php | 370 ++++++++++++++++++ .../src/Services/OpMetadataServiceTest.php | 1 + .../AuthenticatedOAuth2ClientResolverTest.php | 1 + .../src/Utils/RequestParamsResolverTest.php | 206 +++++++++- 36 files changed, 2116 insertions(+), 487 deletions(-) create mode 100644 src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php delete mode 100644 src/Factories/JarFactory.php create mode 100644 src/Server/RequestRules/Rules/RequestUriRule.php create mode 100644 tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php create mode 100644 tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php create mode 100644 tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php diff --git a/hooks/hook_cron.php b/hooks/hook_cron.php index 7751baba..133613fc 100644 --- a/hooks/hook_cron.php +++ b/hooks/hook_cron.php @@ -70,9 +70,9 @@ function oidc_hook_cron(array &$croninfo): void $issuerStateRepository = $container->get(IssuerStateRepository::class); $issuerStateRepository->removeInvalid(); - /** @var \SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository $pushedAuthRepo */ - $pushedAuthRepo = $container->get(PushedAuthorizationRequestRepository::class); - $pushedAuthRepo->deleteExpired(new DateTimeImmutable()); + /** @var \SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository $parRepository */ + $parRepository = $container->get(PushedAuthorizationRequestRepository::class); + $parRepository->removeExpired(); $croninfo['summary'][] = 'Module `oidc` clean up. Removed expired entries from storage.'; } catch (Exception $e) { diff --git a/psalm.xml b/psalm.xml index 83fa4ca0..ce0e8ea4 100644 --- a/psalm.xml +++ b/psalm.xml @@ -50,7 +50,6 @@ - diff --git a/routing/services/services.yml b/routing/services/services.yml index 8a7a0de9..f5ee2c0b 100644 --- a/routing/services/services.yml +++ b/routing/services/services.yml @@ -134,8 +134,6 @@ services: SimpleSAML\OpenID\Did: ~ SimpleSAML\OpenID\Jws: factory: [ '@SimpleSAML\Module\oidc\Factories\JwsFactory', 'build' ] - SimpleSAML\OpenID\Jar: - factory: [ '@SimpleSAML\Module\oidc\Factories\JarFactory', 'build' ] SimpleSAML\OpenID\RequestObject: factory: [ '@SimpleSAML\Module\oidc\Factories\RequestObjectFactory', 'build' ] diff --git a/src/Controllers/Admin/ClientController.php b/src/Controllers/Admin/ClientController.php index 7f1aaade..fb1a093b 100644 --- a/src/Controllers/Admin/ClientController.php +++ b/src/Controllers/Admin/ClientController.php @@ -354,17 +354,17 @@ protected function buildClientEntityFromFormData( $data[ClaimsEnum::IdTokenSignedResponseAlg->value] : null; - $requirePushedAuth = (bool)($data['require_pushed_authorization_requests'] ?? false); - $requireSignedReqObj = (bool)($data['require_signed_request_object'] ?? false); + $requirePushedAuth = (bool)($data[ClaimsEnum::RequirePushedAuthorizationRequests->value] ?? false); + $requireSignedReqObj = (bool)($data[ClaimsEnum::RequireSignedRequestObject->value] ?? false); /** @var mixed $rawRequestUris */ - $rawRequestUris = $data['request_uris'] ?? null; + $rawRequestUris = $data[ClaimsEnum::RequestUris->value] ?? null; $requestUris = is_array($rawRequestUris) ? $rawRequestUris : []; $extraMetadata = [ ClaimsEnum::IdTokenSignedResponseAlg->value => $idTokenSignedResponseAlg, - 'require_pushed_authorization_requests' => $requirePushedAuth, - 'require_signed_request_object' => $requireSignedReqObj, - 'request_uris' => $requestUris, + ClaimsEnum::RequirePushedAuthorizationRequests->value => $requirePushedAuth, + ClaimsEnum::RequireSignedRequestObject->value => $requireSignedReqObj, + ClaimsEnum::RequestUris->value => $requestUris, ]; $allowedResponseModes = is_array($data[ClientEntity::KEY_ALLOWED_RESPONSE_MODES]) ? diff --git a/src/Controllers/PushedAuthorizationController.php b/src/Controllers/PushedAuthorizationController.php index 1542f587..fe2830f5 100644 --- a/src/Controllers/PushedAuthorizationController.php +++ b/src/Controllers/PushedAuthorizationController.php @@ -17,11 +17,11 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; -use SimpleSAML\Module\oidc\Entities\PushedAuthorizationRequestEntity; +use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; use SimpleSAML\Module\oidc\Helpers; -use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; +use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; @@ -29,6 +29,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ScopeRule; @@ -37,9 +38,8 @@ use SimpleSAML\Module\oidc\Services\ErrorResponder; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; -use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; -use SimpleSAML\OpenID\RequestObject; +use SimpleSAML\OpenID\Codebooks\ParamsEnum; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -48,10 +48,8 @@ class PushedAuthorizationController public function __construct( private readonly AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, + private readonly PushedAuthorizationRequestEntityFactory $pushedAuthorizationRequestEntityFactory, private readonly RequestRulesManager $requestRulesManager, - private readonly JwksResolver $jwksResolver, - private readonly RequestObject $requestObject, - private readonly ModuleConfig $moduleConfig, private readonly PsrHttpBridge $psrHttpBridge, private readonly ErrorResponder $errorResponder, private readonly Helpers $helpers, @@ -59,20 +57,24 @@ public function __construct( ) { } + /** + * @throws \League\OAuth2\Server\Exception\OAuthServerException + * @throws \Throwable + */ public function __invoke(ServerRequestInterface $request): ResponseInterface { $this->logger->debug('PushedAuthorizationController::__invoke'); - if (strtoupper($request->getMethod()) !== 'POST') { + if (strtoupper($request->getMethod()) !== HttpMethodsEnum::POST->value) { return $this->psrHttpBridge->getResponseFactory()->createResponse() ->withStatus(405) - ->withHeader('Allow', 'POST'); + ->withHeader('Allow', HttpMethodsEnum::POST->value); } - // 1. Authenticate client + // Authenticate the client in the same way as at the token endpoint. $resolvedAuth = $this->authenticatedOAuth2ClientResolver->forAnySupportedMethod($request); if (is_null($resolvedAuth)) { - throw OidcServerException::accessDenied('Client authentication failed'); + throw OidcServerException::accessDenied('Client authentication failed.'); } $client = $resolvedAuth->getClient(); @@ -81,56 +83,31 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface throw OidcServerException::accessDenied('Confidential client must authenticate.'); } - // 2. Parse request params $bodyParams = $request->getParsedBody(); - $params = is_array($bodyParams) ? $bodyParams : []; + $bodyParams = is_array($bodyParams) ? $bodyParams : []; - // 3. Reject request_uri in PAR body - if (isset($params['request_uri'])) { + // The request_uri authorization request parameter must not be used in pushed authorization requests. + if (array_key_exists(ParamsEnum::RequestUri->value, $bodyParams)) { throw OidcServerException::invalidRequest( - 'request_uri', - 'The request_uri parameter MUST NOT be provided in pushed authorization requests.', + ParamsEnum::RequestUri->value, + 'The request_uri parameter must not be used in pushed authorization requests.', ); } - // 4. Handle JAR in PAR (request parameter) - if (isset($params['request'])) { - try { - $requestObject = $this->requestObject->jarRequestObjectFactory()->fromToken((string)$params['request']); - $jwks = $this->jwksResolver->forClient($client); - if (is_null($jwks)) { - throw OidcServerException::invalidRequest( - 'request', - 'Client JWKS not available for signature verification.', - ); - } - $requestObject->verifyWithKeySet($jwks); - - if ($requestObject->getClientId() !== $client->getIdentifier()) { - throw OidcServerException::invalidRequest( - 'request', - 'Client ID in request object does not match authenticated client.', - ); - } - - $params = array_merge($params, $requestObject->getPayload()); - unset($params['request']); - } catch (\Throwable $t) { - throw OidcServerException::invalidRequest('request', 'Invalid request object: ' . $t->getMessage()); - } - } - - // 5. Build mock request with merged params and run validation rules - $psrRequest = $request->withParsedBody($params)->withQueryParams([]); - + // Validate the pushed params as we would an authorization request sent to the authorization endpoint. + // Note that the rules transparently take the Request Object (request param) into account, with + // RequestObjectRule doing its validation (signature, signed-required policy...). $resultBag = new ResultBag(); $resultBag->add(new Result(ClientRule::class, $client)); - $this->requestRulesManager->predefineResultBag($resultBag); + $this->requestRulesManager->setData('default_scope', ''); + $this->requestRulesManager->setData('scope_delimiter_string', ' '); + $rulesToExecute = [ StateRule::class, ClientRedirectUriRule::class, + RequestObjectRule::class, ResponseModeRule::class, ScopeRule::class, RequiredOpenIdScopeRule::class, @@ -138,41 +115,31 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface CodeChallengeMethodRule::class, ]; - $this->requestRulesManager->setData('default_scope', ''); - $this->requestRulesManager->setData('scope_delimiter_string', ' '); - - $this->requestRulesManager->check( - $psrRequest, + $resultBag = $this->requestRulesManager->check( + $request, $rulesToExecute, new QueryResponseMode(), [HttpMethodsEnum::POST], ); - // 6. Generate request_uri - $hex = bin2hex(random_bytes(32)); - $requestUri = 'urn:ietf:params:oauth:request_uri:' . $hex; - - // 7. Persist entity - $ttl = $this->moduleConfig->getParRequestUriTtl(); - $expiresAt = $this->helpers->dateTime()->getUtc()->add($ttl); - - // Make sure we carry forward all validated params - $entity = new PushedAuthorizationRequestEntity( - requestUri: $requestUri, - clientId: $client->getIdentifier(), - parameters: $params, - expiresAt: \DateTimeImmutable::createFromInterface($expiresAt), - isConsumed: false, + $parameters = $this->resolveParametersToPersist($resultBag, $bodyParams, $client->getIdentifier()); + + $parEntity = $this->pushedAuthorizationRequestEntityFactory->buildNew( + $client->getIdentifier(), + $parameters, ); - $this->pushedAuthorizationRequestRepository->persist($entity); + $this->pushedAuthorizationRequestRepository->persist($parEntity); - // 8. Respond - $expiresIn = $this->helpers->dateTime()->getSecondsToExpirationTime($expiresAt->getTimestamp()); - $responseBody = json_encode([ - 'request_uri' => $requestUri, - 'expires_in' => $expiresIn, - ], JSON_THROW_ON_ERROR); + $responseBody = json_encode( + [ + 'request_uri' => $parEntity->getRequestUri(), + 'expires_in' => $this->helpers->dateTime()->getSecondsToExpirationTime( + $parEntity->getExpiresAt()->getTimestamp(), + ), + ], + JSON_THROW_ON_ERROR, + ); $response = $this->psrHttpBridge->getResponseFactory()->createResponse() ->withStatus(201) @@ -184,6 +151,69 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface return $response; } + /** + * Resolve the authorization request parameters which are to be persisted for later use at the + * authorization endpoint. + * + * @param mixed[] $bodyParams + * @return mixed[] + * @throws \League\OAuth2\Server\Exception\OAuthServerException + */ + protected function resolveParametersToPersist( + ResultBagInterface $resultBag, + array $bodyParams, + string $clientId, + ): array { + // If a body client_id param was provided, it must match the authenticated client. + if ( + array_key_exists(ParamsEnum::ClientId->value, $bodyParams) && + $bodyParams[ParamsEnum::ClientId->value] !== $clientId + ) { + throw OidcServerException::invalidRequest( + ParamsEnum::ClientId->value, + 'The client_id parameter does not match the authenticated client.', + ); + } + + $requestObjectResult = $resultBag->get(RequestObjectRule::class); + + if ($requestObjectResult !== null) { + // Request Object (JAR) was used. Per RFC 9126, all authorization request parameters must appear + // as claims of the Request Object, so only use its (validated) payload. + /** @psalm-suppress MixedAssignment */ + $parameters = $resultBag->getOrFail(RequestObjectRule::class)->getValue(); + $parameters = is_array($parameters) ? $parameters : []; + + /** @psalm-suppress MixedAssignment */ + $clientIdClaim = $parameters[ParamsEnum::ClientId->value] ?? null; + if (!is_null($clientIdClaim) && $clientIdClaim !== $clientId) { + throw OidcServerException::invalidRequest( + ParamsEnum::ClientId->value, + 'The client_id claim in request object does not match the authenticated client.', + ); + } + } else { + // Plain pushed authorization request. Make sure not to persist client authentication related + // params (they are not part of the authorization request itself). + $parameters = $bodyParams; + unset( + $parameters[ParamsEnum::ClientSecret->value], + $parameters[ParamsEnum::ClientAssertion->value], + $parameters[ParamsEnum::ClientAssertionType->value], + ); + } + + unset( + $parameters[ParamsEnum::Request->value], + $parameters[ParamsEnum::RequestUri->value], + ); + + // Bind the parameters to the authenticated client. + $parameters[ParamsEnum::ClientId->value] = $clientId; + + return $parameters; + } + public function par(Request $request): Response { try { @@ -191,10 +221,15 @@ public function par(Request $request): Response $psrResponse = $this->__invoke($psrRequest); return $this->psrHttpBridge->getHttpFoundationFactory()->createResponse($psrResponse); } catch (OAuthServerException $exception) { - return $this->errorResponder->forException($exception); + // Per RFC 9126, the error response format is the one specified for the token endpoint, so make + // sure we never redirect (regardless of any redirect URI contained in the exception). + return $this->errorResponder->forExceptionJson($exception); } catch (\Throwable $exception) { - return $this->errorResponder->forException( - OidcServerException::invalidRequest('request', $exception->getMessage()), + $this->logger->error( + 'PushedAuthorizationController: error processing request: ' . $exception->getMessage(), + ); + return $this->errorResponder->forExceptionJson( + OidcServerException::serverError('Unable to process pushed authorization request.'), ); } } diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php index 2ff79ff0..d006f44f 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -232,9 +232,9 @@ public function toArray(): array // Extra metadata ClaimsEnum::IdTokenSignedResponseAlg->value => $this->getIdTokenSignedResponseAlg(), - 'require_pushed_authorization_requests' => $this->getRequirePushedAuthorizationRequests(), - 'require_signed_request_object' => $this->getRequireSignedRequestObject(), - 'request_uris' => $this->getRequestUris(), + ClaimsEnum::RequirePushedAuthorizationRequests->value => $this->getRequirePushedAuthorizationRequests(), + ClaimsEnum::RequireSignedRequestObject->value => $this->getRequireSignedRequestObject(), + ClaimsEnum::RequestUris->value => $this->getRequestUris(), ]; } @@ -416,7 +416,7 @@ public function getRequirePushedAuthorizationRequests(): bool return false; } - return (bool)($this->extraMetadata['require_pushed_authorization_requests'] ?? false); + return (bool)($this->extraMetadata[ClaimsEnum::RequirePushedAuthorizationRequests->value] ?? false); } public function getRequireSignedRequestObject(): bool @@ -425,7 +425,7 @@ public function getRequireSignedRequestObject(): bool return false; } - return (bool)($this->extraMetadata['require_signed_request_object'] ?? false); + return (bool)($this->extraMetadata[ClaimsEnum::RequireSignedRequestObject->value] ?? false); } /** @@ -438,7 +438,7 @@ public function getRequestUris(): array } /** @var mixed $uris */ - $uris = $this->extraMetadata['request_uris'] ?? null; + $uris = $this->extraMetadata[ClaimsEnum::RequestUris->value] ?? null; if (!is_array($uris)) { return []; } diff --git a/src/Entities/PushedAuthorizationRequestEntity.php b/src/Entities/PushedAuthorizationRequestEntity.php index 7046ede3..99b899ed 100644 --- a/src/Entities/PushedAuthorizationRequestEntity.php +++ b/src/Entities/PushedAuthorizationRequestEntity.php @@ -14,6 +14,7 @@ namespace SimpleSAML\Module\oidc\Entities; use DateTimeImmutable; +use SimpleSAML\Module\oidc\Codebooks\DateFormatsEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\MementoInterface; class PushedAuthorizationRequestEntity implements MementoInterface @@ -57,9 +58,9 @@ public function consume(): void $this->isConsumed = true; } - public function isExpired(): bool + public function isExpired(DateTimeImmutable $now): bool { - return $this->expiresAt < new DateTimeImmutable(); + return $this->expiresAt < $now; } /** @@ -71,7 +72,7 @@ public function getState(): array 'request_uri' => $this->requestUri, 'client_id' => $this->clientId, 'parameters' => json_encode($this->parameters, JSON_THROW_ON_ERROR), - 'expires_at' => $this->expiresAt->format('Y-m-d H:i:s'), + 'expires_at' => $this->expiresAt->format(DateFormatsEnum::DB_DATETIME->value), 'is_consumed' => $this->isConsumed, ]; } diff --git a/src/Factories/AuthorizationServerFactory.php b/src/Factories/AuthorizationServerFactory.php index fffa5513..9970b948 100644 --- a/src/Factories/AuthorizationServerFactory.php +++ b/src/Factories/AuthorizationServerFactory.php @@ -20,7 +20,6 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; -use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\ScopeRepository; use SimpleSAML\Module\oidc\Server\AuthorizationServer; use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; @@ -30,9 +29,6 @@ use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\ResponseTypes\TokenResponse; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\JwksResolver; -use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; -use SimpleSAML\OpenID\RequestObject; class AuthorizationServerFactory { @@ -49,10 +45,6 @@ public function __construct( private readonly CryptKey $privateKey, private readonly PreAuthCodeGrant $preAuthCodeGrant, private readonly LoggerService $loggerService, - private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, - private readonly RequestParamsResolver $requestParamsResolver, - private readonly JwksResolver $jwksResolver, - private readonly RequestObject $requestObject, ) { } @@ -67,11 +59,6 @@ public function build(): AuthorizationServer $this->tokenResponse, $this->requestRulesManager, $this->loggerService, - $this->pushedAuthorizationRequestRepository, - $this->requestParamsResolver, - $this->jwksResolver, - $this->requestObject, - $this->moduleConfig, ); $authorizationServer->enableGrantType( diff --git a/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php b/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php new file mode 100644 index 00000000..02a59f4a --- /dev/null +++ b/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php @@ -0,0 +1,84 @@ +helpers->dateTime()->getUtc() + ->add($this->moduleConfig->getParRequestUriTtl()); + + return new PushedAuthorizationRequestEntity( + $requestUri, + $clientId, + $parameters, + $expiresAt, + ); + } + + /** + * @param mixed[] $state + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * @throws \JsonException + * @throws \Exception + */ + public function fromState(array $state): PushedAuthorizationRequestEntity + { + if ( + !is_string($requestUri = $state['request_uri']) || + !is_string($clientId = $state['client_id']) || + !is_string($parametersJson = $state['parameters']) || + !is_string($expiresAt = $state['expires_at']) + ) { + throw new OpenIdException('Invalid Pushed Authorization Request Entity state.'); + } + + /** @psalm-suppress MixedAssignment */ + $parameters = json_decode($parametersJson, true, 512, JSON_THROW_ON_ERROR); + + $isConsumed = (bool)($state['is_consumed'] ?? true); + + return new PushedAuthorizationRequestEntity( + $requestUri, + $clientId, + is_array($parameters) ? $parameters : [], + $this->helpers->dateTime()->getUtc($expiresAt), + $isConsumed, + ); + } +} diff --git a/src/Factories/Grant/AuthCodeGrantFactory.php b/src/Factories/Grant/AuthCodeGrantFactory.php index f0327e7d..5b90a452 100644 --- a/src/Factories/Grant/AuthCodeGrantFactory.php +++ b/src/Factories/Grant/AuthCodeGrantFactory.php @@ -22,7 +22,6 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; -use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; @@ -44,7 +43,6 @@ public function __construct( private readonly RefreshTokenIssuer $refreshTokenIssuer, private readonly Helpers $helpers, private readonly LoggerService $loggerService, - private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, ) { } @@ -65,7 +63,6 @@ public function build(): AuthCodeGrant $this->refreshTokenIssuer, $this->helpers, $this->loggerService, - $this->pushedAuthorizationRequestRepository, ); $authCodeGrant->setRefreshTokenTTL($this->moduleConfig->getRefreshTokenDuration()); diff --git a/src/Factories/Grant/PreAuthCodeGrantFactory.php b/src/Factories/Grant/PreAuthCodeGrantFactory.php index 2b9d7720..9e241c3f 100644 --- a/src/Factories/Grant/PreAuthCodeGrantFactory.php +++ b/src/Factories/Grant/PreAuthCodeGrantFactory.php @@ -22,7 +22,6 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; -use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Server\Grants\PreAuthCodeGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; @@ -44,7 +43,6 @@ public function __construct( private readonly RefreshTokenIssuer $refreshTokenIssuer, private readonly Helpers $helpers, private readonly LoggerService $loggerService, - private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, ) { } @@ -65,7 +63,6 @@ public function build(): PreAuthCodeGrant $this->refreshTokenIssuer, $this->helpers, $this->loggerService, - $this->pushedAuthorizationRequestRepository, ); $preAuthCodeGrant->setRefreshTokenTTL($this->moduleConfig->getRefreshTokenDuration()); diff --git a/src/Factories/JarFactory.php b/src/Factories/JarFactory.php deleted file mode 100644 index d8864eea..00000000 --- a/src/Factories/JarFactory.php +++ /dev/null @@ -1,31 +0,0 @@ -moduleConfig->getSupportedAlgorithms(), - timestampValidationLeeway: $this->moduleConfig->getTimestampValidationLeeway(), - ); - } -} diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index 33e7df74..ee8c78e4 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -10,6 +10,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\CodeChallengeVerifiersRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\ScopeRepository; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AcrValuesRule; @@ -29,6 +30,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule; @@ -74,6 +76,7 @@ public function __construct( private readonly Jwks $jwks, private readonly Core $core, private readonly AuthenticatedOAuth2ClientResolver $authenticatedOAuth2ClientResolver, + private readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, private readonly ?FederationCache $federationCache = null, private readonly ?ProtocolCache $protocolCache = null, private readonly QueryResponseMode $queryResponseMode, @@ -119,6 +122,13 @@ private function getDefaultRules(): array $this->jwksResolver, $this->moduleConfig, ), + new RequestUriRule( + $this->requestParamsResolver, + $this->helpers, + $this->pushedAuthorizationRequestRepository, + $this->jwksResolver, + $this->moduleConfig, + ), new ResponseModeRule( $this->requestParamsResolver, $this->helpers, diff --git a/src/Forms/ClientForm.php b/src/Forms/ClientForm.php index b6b2081a..1953faf0 100644 --- a/src/Forms/ClientForm.php +++ b/src/Forms/ClientForm.php @@ -182,7 +182,9 @@ public function validateJwksUri(Form $form): void public function validateRequestUris(Form $form): void { $values = $form->getValues(self::TYPE_ARRAY); - $requestUris = $this->helpers->str()->convertTextToArray((string)($values['request_uris'] ?? '')); + $requestUris = $this->helpers->str()->convertTextToArray( + (string)($values[ClaimsEnum::RequestUris->value] ?? ''), + ); foreach ($requestUris as $uri) { if (!str_starts_with(strtolower($uri), 'https://')) { $this->addError('Request URI must be an HTTPS URL: ' . $uri); @@ -290,7 +292,11 @@ public function getValues(string|object|bool|null $returnType = null, ?array $co $signedJwksUri = trim((string)$values['signed_jwks_uri']); $values['signed_jwks_uri'] = empty($signedJwksUri) ? null : $signedJwksUri; - $values['request_uris'] = $this->helpers->str()->convertTextToArray((string)($values['request_uris'] ?? '')); + /** @var mixed $requestUrisValue */ + $requestUrisValue = $values[ClaimsEnum::RequestUris->value] ?? ''; + $values[ClaimsEnum::RequestUris->value] = $this->helpers->str()->convertTextToArray( + is_string($requestUrisValue) ? $requestUrisValue : '', + ); $idTokenSignedResponseAlg = trim((string)$values[ClaimsEnum::IdTokenSignedResponseAlg->value]); $values[ClaimsEnum::IdTokenSignedResponseAlg->value] = empty($idTokenSignedResponseAlg) ? @@ -357,8 +363,9 @@ public function setDefaults(object|array $values, bool $erase = false): static $values['auth_source'] = null; } - $requestUris = isset($values['request_uris']) && is_array($values['request_uris']) ? - $values['request_uris'] : + $requestUris = isset($values[ClaimsEnum::RequestUris->value]) && + is_array($values[ClaimsEnum::RequestUris->value]) ? + $values[ClaimsEnum::RequestUris->value] : []; $stringUris = []; /** @var mixed $uri */ @@ -367,7 +374,7 @@ public function setDefaults(object|array $values, bool $erase = false): static $stringUris[] = $uri; } } - $values['request_uris'] = implode("\n", $stringUris); + $values[ClaimsEnum::RequestUris->value] = implode("\n", $stringUris); $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES] = is_array( $values[ClientEntity::KEY_ALLOWED_RESPONSE_MODES], @@ -471,9 +478,12 @@ protected function buildForm(): void )->setHtmlAttribute('class', 'full-width') ->setRequired(Translate::noop('At least one response mode is required.')); - $this->addCheckbox('require_pushed_authorization_requests', 'Require Pushed Authorization Requests (PAR)'); - $this->addCheckbox('require_signed_request_object', 'Require Signed Request Object'); - $this->addTextArea('request_uris', 'Request URIs (OIDC Core / JAR, one per line)', null, 5) + $this->addCheckbox( + ClaimsEnum::RequirePushedAuthorizationRequests->value, + 'Require Pushed Authorization Requests (PAR)', + ); + $this->addCheckbox(ClaimsEnum::RequireSignedRequestObject->value, 'Require Signed Request Object'); + $this->addTextArea(ClaimsEnum::RequestUris->value, 'Request URIs (OIDC Core / JAR, one per line)', null, 5) ->setHtmlAttribute('class', 'full-width'); } diff --git a/src/Repositories/PushedAuthorizationRequestRepository.php b/src/Repositories/PushedAuthorizationRequestRepository.php index a1494e4e..73d4fd04 100644 --- a/src/Repositories/PushedAuthorizationRequestRepository.php +++ b/src/Repositories/PushedAuthorizationRequestRepository.php @@ -13,88 +13,120 @@ namespace SimpleSAML\Module\oidc\Repositories; -use DateTimeImmutable; use PDO; +use SimpleSAML\Database; +use SimpleSAML\Module\oidc\Codebooks\DateFormatsEnum; use SimpleSAML\Module\oidc\Entities\PushedAuthorizationRequestEntity; +use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; +use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Utils\ProtocolCache; class PushedAuthorizationRequestRepository extends AbstractDatabaseRepository { final public const string TABLE_NAME = 'oidc_par'; + public function __construct( + ModuleConfig $moduleConfig, + Database $database, + ?ProtocolCache $protocolCache, + protected readonly PushedAuthorizationRequestEntityFactory $pushedAuthorizationRequestEntityFactory, + protected readonly Helpers $helpers, + ) { + parent::__construct($moduleConfig, $database, $protocolCache); + } + public function getTableName(): string { return $this->database->applyPrefix(self::TABLE_NAME); } /** - * Persist the PAR entity in the database. + * Persist the Pushed Authorization Request entity in the database. * * @throws \JsonException */ public function persist(PushedAuthorizationRequestEntity $entity): void { - $state = $entity->getState(); - $stmt = "INSERT INTO {$this->getTableName()} (request_uri, client_id, parameters, expires_at, is_consumed) " . "VALUES (:request_uri, :client_id, :parameters, :expires_at, :is_consumed)"; - $this->database->write($stmt, [ - 'request_uri' => $state['request_uri'], - 'client_id' => $state['client_id'], - 'parameters' => $state['parameters'], - 'expires_at' => $state['expires_at'], - 'is_consumed' => $state['is_consumed'] ? 1 : 0, - ]); + $state = $entity->getState(); + $state['is_consumed'] = (int)$state['is_consumed']; + + $this->database->write($stmt, $state); } /** - * Find PAR entity by request_uri. + * Find Pushed Authorization Request entity by request_uri. * + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * @throws \JsonException * @throws \Exception */ - public function findByRequestUri(string $requestUri): ?PushedAuthorizationRequestEntity + public function find(string $requestUri): ?PushedAuthorizationRequestEntity { $stmt = $this->database->read( - "SELECT client_id, parameters, expires_at, is_consumed " . - "FROM {$this->getTableName()} WHERE request_uri = :request_uri LIMIT 1", + "SELECT request_uri, client_id, parameters, expires_at, is_consumed " . + "FROM {$this->getTableName()} WHERE request_uri = :request_uri", ['request_uri' => $requestUri], ); - $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($row = $stmt->fetch(PDO::FETCH_ASSOC))) { + return null; + } + + return $this->pushedAuthorizationRequestEntityFactory->fromState($row); + } - if (!$row) { + /** + * Find Pushed Authorization Request entity which is not consumed nor expired. + * + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * @throws \JsonException + * @throws \Exception + */ + public function findValid(string $requestUri): ?PushedAuthorizationRequestEntity + { + $entity = $this->find($requestUri); + + if ($entity === null) { return null; } - /** @psalm-suppress MixedAssignment */ - $decoded = json_decode((string)$row['parameters'], true, 512, JSON_THROW_ON_ERROR); - $parameters = is_array($decoded) ? $decoded : []; + if ($entity->isConsumed()) { + return null; + } - return new PushedAuthorizationRequestEntity( - requestUri: $requestUri, - clientId: (string)$row['client_id'], - parameters: $parameters, - expiresAt: new DateTimeImmutable((string)$row['expires_at']), - isConsumed: (bool)$row['is_consumed'], - ); + if ($entity->isExpired($this->helpers->dateTime()->getUtc())) { + return null; + } + + return $entity; } /** - * Mark a PAR record as consumed. + * Mark the Pushed Authorization Request as consumed (one-time use). Atomic, so it can be used as a replay + * guard: returns true only if this call was the one that consumed it. */ - public function consume(string $requestUri): void + public function consume(string $requestUri): bool { - $stmt = "UPDATE {$this->getTableName()} SET is_consumed = 1 WHERE request_uri = :request_uri"; - $this->database->write($stmt, ['request_uri' => $requestUri]); + $stmt = "UPDATE {$this->getTableName()} SET is_consumed = 1 " . + "WHERE request_uri = :request_uri AND is_consumed = 0"; + + $affected = $this->database->write($stmt, ['request_uri' => $requestUri]); + + return is_int($affected) && $affected > 0; } /** - * Delete expired PAR records. + * Delete expired Pushed Authorization Request records. */ - public function deleteExpired(DateTimeImmutable $now): int + public function removeExpired(): void { - $stmt = "DELETE FROM {$this->getTableName()} WHERE expires_at < :now"; - $result = $this->database->write($stmt, ['now' => $now->format('Y-m-d H:i:s')]); - return is_int($result) ? $result : 0; + $this->database->write( + "DELETE FROM {$this->getTableName()} WHERE expires_at < :now", + ['now' => $this->helpers->dateTime()->getUtc()->format(DateFormatsEnum::DB_DATETIME->value)], + ); } } diff --git a/src/Server/AuthorizationServer.php b/src/Server/AuthorizationServer.php index 8ad4218a..35ed30cb 100644 --- a/src/Server/AuthorizationServer.php +++ b/src/Server/AuthorizationServer.php @@ -15,8 +15,6 @@ use LogicException; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Error\BadRequest; -use SimpleSAML\Module\oidc\ModuleConfig; -use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\Grants\Interfaces\AuthorizationValidatableWithRequestRules; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; @@ -24,17 +22,14 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IdTokenHintRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PostLogoutRedirectUriRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\UiLocalesRule; -use SimpleSAML\Module\oidc\Server\RequestTypes\AuthorizationRequest; use SimpleSAML\Module\oidc\Server\RequestTypes\LogoutRequest; use SimpleSAML\Module\oidc\Server\ResponseModes\QueryResponseMode; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\JwksResolver; -use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; -use SimpleSAML\OpenID\RequestObject; class AuthorizationServer extends OAuth2AuthorizationServer { @@ -43,12 +38,6 @@ class AuthorizationServer extends OAuth2AuthorizationServer protected RequestRulesManager $requestRulesManager; - protected ?PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository = null; - protected ?RequestParamsResolver $requestParamsResolver = null; - protected ?JwksResolver $jwksResolver = null; - protected ?RequestObject $requestObject = null; - protected ?ModuleConfig $moduleConfig = null; - /** * @var \League\OAuth2\Server\CryptKey * @psalm-suppress PropertyNotSetInConstructor @@ -67,11 +56,6 @@ public function __construct( ?ResponseTypeInterface $responseType = null, ?RequestRulesManager $requestRulesManager = null, protected readonly ?LoggerService $loggerService = null, - ?PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository = null, - ?RequestParamsResolver $requestParamsResolver = null, - ?JwksResolver $jwksResolver = null, - ?RequestObject $requestObject = null, - ?ModuleConfig $moduleConfig = null, ) { parent::__construct( $clientRepository, @@ -88,12 +72,6 @@ public function __construct( throw new LogicException('Can not validate request (no RequestRulesManager defined)'); } $this->requestRulesManager = $requestRulesManager; - - $this->pushedAuthorizationRequestRepository = $pushedAuthorizationRequestRepository; - $this->requestParamsResolver = $requestParamsResolver; - $this->jwksResolver = $jwksResolver; - $this->requestObject = $requestObject; - $this->moduleConfig = $moduleConfig; } /** @@ -106,172 +84,15 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O { $this->loggerService?->debug('AuthorizationServer::validateAuthorizationRequest'); - try { - $queryParams = $request->getQueryParams(); - $bodyParams = $request->getParsedBody(); - $params = array_merge($queryParams, is_array($bodyParams) ? $bodyParams : []); - - $requestUri = isset($params['request_uri']) && is_string($params['request_uri']) ? - $params['request_uri'] : - null; - $parRequestUri = null; - - if (is_string($requestUri) && $requestUri !== '') { - if (str_starts_with($requestUri, 'urn:ietf:params:oauth:request_uri:')) { - if ($this->pushedAuthorizationRequestRepository === null) { - throw new LogicException('PushedAuthorizationRequestRepository is not configured.'); - } - $parEntity = $this->pushedAuthorizationRequestRepository->findByRequestUri($requestUri); - if ($parEntity === null) { - throw OidcServerException::invalidRequest( - 'request_uri', - 'Pushed authorization request not found or expired.', - ); - } - if ($parEntity->isConsumed()) { - throw OidcServerException::invalidRequest( - 'request_uri', - 'Pushed authorization request has already been consumed.', - ); - } - - $clientId = isset($params['client_id']) && is_string($params['client_id']) ? - $params['client_id'] : - null; - if ($clientId !== null && $clientId !== $parEntity->getClientId()) { - throw OidcServerException::invalidRequest( - 'client_id', - 'Client ID does not match the pushed authorization request client ID.', - ); - } - - $request = $request->withQueryParams($parEntity->getParameters())->withParsedBody([]); - $parRequestUri = $requestUri; - } elseif (str_starts_with(strtolower($requestUri), 'https://')) { - if ( - $this->requestParamsResolver === null || - $this->jwksResolver === null || - $this->requestObject === null || - $this->moduleConfig === null - ) { - throw new LogicException( - 'Required dependencies for JAR request_uri fetching are not configured.', - ); - } - - $clientId = isset($params['client_id']) && is_string($params['client_id']) ? - $params['client_id'] : - null; - if (empty($clientId)) { - throw OidcServerException::invalidRequest( - 'client_id', - 'Client ID is required when using request_uri.', - ); - } - - - - $client = $this->clientRepository->getClientEntity($clientId); - if ($client === null) { - throw OidcServerException::invalidRequest('client_id', 'Client not found.'); - } - - if (!$client instanceof \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface) { - throw OidcServerException::invalidRequest( - 'request_uri', - 'Client is not supported.', - ); - } - - $allowedRequestUris = $client->getRequestUris(); - if (!in_array($requestUri, $allowedRequestUris, true)) { - throw OidcServerException::invalidRequest( - 'request_uri', - 'The request_uri is not registered for this client.', - ); - } - - try { - $jwtString = $this->requestObject->requestUriFetcher()->fetch( - $requestUri, - $this->moduleConfig->getRequestUriTimeout(), - $this->moduleConfig->getRequestUriMaxSizeBytes(), - ); - } catch (\Throwable $t) { - throw OidcServerException::invalidRequest( - 'request_uri', - 'Failed to fetch request_uri: ' . $t->getMessage(), - ); - } - - try { - $requestObject = $this->requestObject->jarRequestObjectFactory()->fromToken($jwtString); - $jwks = $this->jwksResolver->forClient($client); - if ($jwks === null) { - throw new \Exception('Client JWKS not available.'); - } - $requestObject->verifyWithKeySet($jwks); - - if ($requestObject->getClientId() !== $client->getIdentifier()) { - throw new \Exception('client_id claim in request object does not match.'); - } - - $jwtPayload = $requestObject->getPayload(); - $mergedParams = array_merge($params, $jwtPayload); - unset($mergedParams['request_uri']); - unset($mergedParams['request']); - - $request = $request->withQueryParams($mergedParams)->withParsedBody([]); - } catch (\Throwable $t) { - throw OidcServerException::invalidRequest( - 'request_uri', - 'Invalid Request Object at request_uri: ' . $t->getMessage(), - ); - } - } else { - throw OidcServerException::invalidRequest( - 'request_uri', - 'Invalid request_uri scheme/format.', - ); - } - } - - // Check if PAR is required - $currentQueryParams = $request->getQueryParams(); - $currentBodyParams = $request->getParsedBody(); - $currentParams = array_merge( - $currentQueryParams, - is_array($currentBodyParams) ? $currentBodyParams : [], - ); - $resolvedClientId = isset($currentParams['client_id']) && is_string($currentParams['client_id']) ? - $currentParams['client_id'] : - null; - - $parRequired = $this->moduleConfig?->getRequirePushedAuthorizationRequests() ?? false; - - if (is_string($resolvedClientId) && $resolvedClientId !== '') { - $resolvedClient = $this->clientRepository->getClientEntity($resolvedClientId); - if ($resolvedClient instanceof \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface) { - if ($resolvedClient->getRequirePushedAuthorizationRequests()) { - $parRequired = true; - } - } - } - - if ($parRequired && $parRequestUri === null) { - throw OidcServerException::invalidRequest( - 'request_uri', - 'Pushed Authorization Request (PAR) is required.', - ); - } - - $rulesToExecute = [ - StateRule::class, - ClientRule::class, - ClientRedirectUriRule::class, - ResponseModeRule::class, - ]; + $rulesToExecute = [ + StateRule::class, + ClientRule::class, + RequestUriRule::class, + ClientRedirectUriRule::class, + ResponseModeRule::class, + ]; + try { $resultBag = $this->requestRulesManager->check( $request, $rulesToExecute, @@ -329,11 +150,7 @@ public function validateAuthorizationRequest(ServerRequestInterface $request): O ), ); - $authorizationRequest = $grantType->validateAuthorizationRequestWithRequestRules($request, $resultBag); - if ($authorizationRequest instanceof AuthorizationRequest && isset($parRequestUri)) { - $authorizationRequest->setParRequestUri($parRequestUri); - } - return $authorizationRequest; + return $grantType->validateAuthorizationRequestWithRequestRules($request, $resultBag); } else { $this->loggerService?->debug( 'AuthorizationServer: Grant type can NOT respond to ' . diff --git a/src/Server/Grants/AuthCodeGrant.php b/src/Server/Grants/AuthCodeGrant.php index a6aaba28..ff62f578 100644 --- a/src/Server/Grants/AuthCodeGrant.php +++ b/src/Server/Grants/AuthCodeGrant.php @@ -36,7 +36,6 @@ use SimpleSAML\Module\oidc\Repositories\Interfaces\AccessTokenRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\AuthCodeRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\RefreshTokenRepositoryInterface; -use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\Grants\Interfaces\AuthorizationValidatableWithRequestRules; use SimpleSAML\Module\oidc\Server\Grants\Interfaces\OidcCapableGrantTypeInterface; @@ -180,7 +179,6 @@ public function __construct( protected RefreshTokenIssuer $refreshTokenIssuer, protected Helpers $helpers, protected LoggerService $loggerService, - protected PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, ) { parent::__construct($authCodeRepository, $refreshTokenRepository, $authCodeTTL); @@ -307,11 +305,6 @@ public function completeOidcAuthorizationRequest( // parameter. Use storage instead. ]; - $parRequestUri = $authorizationRequest->getParRequestUri(); - if ($parRequestUri !== null) { - $this->pushedAuthorizationRequestRepository->consume($parRequestUri); - } - $jsonPayload = json_encode($payload, JSON_THROW_ON_ERROR); $responseMode = $authorizationRequest->getResponseMode() ?? new QueryResponseMode(); diff --git a/src/Server/RequestRules/Rules/AbstractRule.php b/src/Server/RequestRules/Rules/AbstractRule.php index 3882cdf9..b7ac999b 100644 --- a/src/Server/RequestRules/Rules/AbstractRule.php +++ b/src/Server/RequestRules/Rules/AbstractRule.php @@ -4,9 +4,13 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; +use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\RequestRuleInterface; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; +use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; +use SimpleSAML\OpenID\Codebooks\ParamsEnum; +use SimpleSAML\OpenID\Codebooks\ScopesEnum; abstract class AbstractRule implements RequestRuleInterface { @@ -23,4 +27,24 @@ public function getKey(): string { return static::class; } + + /** + * Check if the authorization request is an OpenID Connect request (designated by the openid scope), as + * opposed to a plain OAuth 2.0 request. Scope is resolved from all request params, including the ones + * from Request Object / Request URI, if present. + * + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods + */ + protected function isOidcAuthorizationRequest( + ServerRequestInterface $request, + array $allowedServerRequestMethods = [HttpMethodsEnum::GET], + ): bool { + $scope = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods( + ParamsEnum::Scope->value, + $request, + $allowedServerRequestMethods, + ) ?? ''; + + return in_array(ScopesEnum::OpenId->value, explode(' ', $scope), true); + } } diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index e29c48b0..526105bf 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules; use Psr\Http\Message\ServerRequestInterface; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; @@ -18,6 +19,8 @@ use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; +use SimpleSAML\OpenID\Core\RequestObject as ConnectRequestObject; +use SimpleSAML\OpenID\Jar\RequestObject as JarRequestObject; class RequestObjectRule extends AbstractRule { @@ -65,10 +68,11 @@ public function checkRule( return null; } - // There is no request object already resolved. We will do it now. - $requestObject = $this->requestParamsResolver->parseRequestObjectToken($requestParam); + // There is no request object already resolved. We will do it now. Parse it using all available Request + // Object flavors, so we can differentiate between OpenID Connect Core Request Objects (which can be + // unsigned) and JAR Request Objects (which must be signed). + $requestObjectBag = $this->requestParamsResolver->parseRequestObjectBag($requestParam); - // If request object is not protected (signed), check if signature is required. /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); /** @var string $redirectUri */ @@ -76,6 +80,52 @@ public function checkRule( /** @var ?string $stateValue */ $stateValue = ($currentResultBag->get(StateRule::class))?->getValue(); + if (!$this->isOidcAuthorizationRequest($request, $allowedServerRequestMethods)) { + // This is a plain OAuth 2.0 authorization request, so JAR (RFC 9101) rules apply: the Request + // Object must be a signed JWT containing the Client ID claim. + $jarRequestObject = $requestObjectBag->get(JarRequestObject::class); + if (!$jarRequestObject instanceof JarRequestObject) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object is not a valid JAR Request Object (note that it must be signed).', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + + if ($jarRequestObject->getClientId() !== $client->getIdentifier()) { + throw OidcServerException::invalidRequest( + 'request', + 'Client ID claim in request object does not match the client_id parameter.', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + + $this->verifySignature($jarRequestObject, $client, $redirectUri, $stateValue, $responseMode); + + return new Result($this->getKey(), $jarRequestObject->getPayload()); + } + + // This is an OpenID Connect authorization request, so OpenID Connect Core rules apply: the Request + // Object can be unsigned (unless signature is required by policy). + $requestObject = $requestObjectBag->get(ConnectRequestObject::class); + if (!$requestObject instanceof ConnectRequestObject) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object is not a valid Request Object.', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + + // If request object is not protected (signed), check if signature is required. if (!$requestObject->isProtected()) { $requireSigned = $this->moduleConfig->getRequireSignedRequestObject() || $client->getRequireSignedRequestObject(); @@ -93,7 +143,21 @@ public function checkRule( } // It is protected, we must validate it. + $this->verifySignature($requestObject, $client, $redirectUri, $stateValue, $responseMode); + return new Result($this->getKey(), $requestObject->getPayload()); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function verifySignature( + ConnectRequestObject|JarRequestObject $requestObject, + ClientEntityInterface $client, + string $redirectUri, + ?string $stateValue, + ResponseModeInterface $responseMode, + ): void { ($jwks = $this->jwksResolver->forClient($client)) || throw OidcServerException::accessDenied( 'can not validate request object, client JWKS not available', $redirectUri, @@ -113,7 +177,5 @@ public function checkRule( $responseMode, ); } - - return new Result($this->getKey(), $requestObject->getPayload()); } } diff --git a/src/Server/RequestRules/Rules/RequestUriRule.php b/src/Server/RequestRules/Rules/RequestUriRule.php new file mode 100644 index 00000000..202a1db9 --- /dev/null +++ b/src/Server/RequestRules/Rules/RequestUriRule.php @@ -0,0 +1,331 @@ +debug('RequestUriRule::checkRule'); + + // Note: we are intentionally working with raw request params here (not the merged view which includes + // params resolved from the request_uri itself). + $requestUri = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::RequestUri->value, + $request, + $allowedServerRequestMethods, + ); + + /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ + $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); + + $isParRequired = $this->moduleConfig->getRequirePushedAuthorizationRequests() || + $client->getRequirePushedAuthorizationRequests(); + + if (is_null($requestUri) || $requestUri === '') { + if ($isParRequired) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed Authorization Request (PAR) is required.', + ); + } + + return null; + } + + $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::Request->value, + $request, + $allowedServerRequestMethods, + ); + + if (!is_null($requestParam)) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Params request and request_uri must not be used together.', + ); + } + + $clientIdParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::ClientId->value, + $request, + $allowedServerRequestMethods, + ); + + if (is_null($clientIdParam) || $clientIdParam === '') { + throw OidcServerException::invalidRequest( + ParamsEnum::ClientId->value, + 'Param client_id is required when using request_uri.', + ); + } + + if (str_starts_with($requestUri, PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX)) { + return $this->checkPushedAuthorizationRequestUri( + $requestUri, + $clientIdParam, + $client, + $loggerService, + ); + } + + if (str_starts_with(strtolower($requestUri), 'https://')) { + return $this->checkHttpsRequestUri( + $requestUri, + $client, + $request, + $currentResultBag, + $isParRequired, + $allowedServerRequestMethods, + ); + } + + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Invalid request_uri scheme / format.', + ); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \Throwable + */ + protected function checkPushedAuthorizationRequestUri( + string $requestUri, + string $clientIdParam, + ClientEntityInterface $client, + LoggerService $loggerService, + ): ResultInterface { + $parEntity = $this->pushedAuthorizationRequestRepository->find($requestUri); + + if ($parEntity === null) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed authorization request not found.', + ); + } + + if ($parEntity->isExpired($this->helpers->dateTime()->getUtc())) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed authorization request has expired.', + ); + } + + if ($parEntity->isConsumed()) { + $loggerService->warning( + 'RequestUriRule: pushed authorization request replay attempt.', + compact('requestUri'), + ); + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed authorization request has already been used.', + ); + } + + // The request_uri value must be bound to the client that posted the authorization request. + if ( + $parEntity->getClientId() !== $clientIdParam || + $parEntity->getClientId() !== $client->getIdentifier() + ) { + throw OidcServerException::invalidRequest( + ParamsEnum::ClientId->value, + 'Pushed authorization request is bound to different client.', + ); + } + + // Request URIs are one-time use. Consume it now (atomically, to prevent concurrent replays). + if (!$this->pushedAuthorizationRequestRepository->consume($requestUri)) { + $loggerService->warning( + 'RequestUriRule: pushed authorization request concurrent consumption attempt.', + compact('requestUri'), + ); + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed authorization request has already been used.', + ); + } + + return new Result($this->getKey(), $requestUri); + } + + /** + * @param HttpMethodsEnum[] $allowedServerRequestMethods + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \Throwable + */ + protected function checkHttpsRequestUri( + string $requestUri, + ClientEntityInterface $client, + ServerRequestInterface $request, + ResultBagInterface $currentResultBag, + bool $isParRequired, + array $allowedServerRequestMethods, + ): ResultInterface { + if ($isParRequired) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Pushed Authorization Request (PAR) is required.', + ); + } + + if (!in_array($requestUri, $client->getRequestUris(), true)) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'The request_uri is not registered for this client.', + ); + } + + // Make sure the request_uri resolution ran (it is memoized in RequestParamsResolver, so this is + // cheap if other rules already triggered it), then grab the resolved Request Object Bag. + $this->requestParamsResolver->getAllBasedOnAllowedMethods($request, $allowedServerRequestMethods); + + $requestObjectBag = $this->requestParamsResolver->getResolvedRequestUriBag($requestUri); + if ($requestObjectBag === null) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Could not fetch or parse the Request Object from request_uri.', + ); + } + + if (!$this->isOidcAuthorizationRequest($request, $allowedServerRequestMethods)) { + // This is a plain OAuth 2.0 authorization request, so JAR (RFC 9101) rules apply: the Request + // Object must be a signed JWT containing the Client ID claim. + $requestObject = $requestObjectBag->get(JarRequestObject::class); + if (!$requestObject instanceof JarRequestObject) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Request object is not a valid JAR Request Object (note that it must be signed).', + ); + } + + $this->verifySignature($requestObject, $client); + } else { + // This is an OpenID Connect authorization request, so OpenID Connect Core rules apply: the + // Request Object can be unsigned (unless signature is required by policy). + $requestObject = $requestObjectBag->get(ConnectRequestObject::class); + if (!$requestObject instanceof ConnectRequestObject) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Request object is not a valid Request Object.', + ); + } + + if ($requestObject->isProtected()) { + $this->verifySignature($requestObject, $client); + } elseif ( + $this->moduleConfig->getRequireSignedRequestObject() || + $client->getRequireSignedRequestObject() + ) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Request object must be signed (alg: none is not allowed).', + ); + } + } + + $payload = $requestObject->getPayload(); + + /** @psalm-suppress MixedAssignment */ + $clientIdClaim = $payload[ParamsEnum::ClientId->value] ?? null; + if ($clientIdClaim !== $client->getIdentifier()) { + throw OidcServerException::invalidRequest( + ParamsEnum::RequestUri->value, + 'Client ID claim in request object does not match the client_id parameter.', + ); + } + + // Mark the Request Object as resolved (and validated), so that RequestObjectRule does not need to + // run again for it. + $currentResultBag->add(new Result(RequestObjectRule::class, $payload)); + + return new Result($this->getKey(), $requestUri); + } + + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function verifySignature( + ConnectRequestObject|JarRequestObject $requestObject, + ClientEntityInterface $client, + ): void { + ($jwks = $this->jwksResolver->forClient($client)) || throw OidcServerException::accessDenied( + 'can not validate request object, client JWKS not available', + ); + + try { + $requestObject->verifyWithKeySet($jwks); + } catch (\Throwable $exception) { + throw OidcServerException::accessDenied( + 'request object validation failed: ' . $exception->getMessage(), + ); + } + } +} diff --git a/src/Server/RequestTypes/AuthorizationRequest.php b/src/Server/RequestTypes/AuthorizationRequest.php index c392d36d..bc969f10 100644 --- a/src/Server/RequestTypes/AuthorizationRequest.php +++ b/src/Server/RequestTypes/AuthorizationRequest.php @@ -73,8 +73,6 @@ class AuthorizationRequest extends OAuth2AuthorizationRequest private ?ResponseModeInterface $responseMode = null; - protected ?string $parRequestUri = null; - public static function fromOAuth2AuthorizationRequest( OAuth2AuthorizationRequest $oAuth2authorizationRequest, ): AuthorizationRequest { @@ -305,14 +303,4 @@ public function setBoundRedirectUri(?string $boundRedirectUri): void { $this->boundRedirectUri = $boundRedirectUri; } - - public function getParRequestUri(): ?string - { - return $this->parRequestUri; - } - - public function setParRequestUri(?string $parRequestUri): void - { - $this->parRequestUri = $parRequestUri; - } } diff --git a/src/Services/Container.php b/src/Services/Container.php index b2e26c22..5dc6cccb 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -44,6 +44,7 @@ use SimpleSAML\Module\oidc\Factories\Entities\ClaimSetEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\IssuerStateEntityFactory; +use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\RefreshTokenEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ScopeEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; @@ -56,6 +57,7 @@ use SimpleSAML\Module\oidc\Factories\JwksFactory; use SimpleSAML\Module\oidc\Factories\JwsFactory; use SimpleSAML\Module\oidc\Factories\ProcessingChainFactory; +use SimpleSAML\Module\oidc\Factories\RequestObjectFactory; use SimpleSAML\Module\oidc\Factories\TemplateFactory; use SimpleSAML\Module\oidc\Factories\TokenResponseFactory; use SimpleSAML\Module\oidc\Forms\Controls\CsrfProtection; @@ -91,6 +93,7 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestUriRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredNonceRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequiredOpenIdScopeRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ResponseModeRule; @@ -263,9 +266,6 @@ public function __construct() ); $this->services[PsrHttpBridge::class] = $psrHttpBridge; - $requestParamsResolver = new RequestParamsResolver($helpers, $core, $federation, $psrHttpBridge); - $this->services[RequestParamsResolver::class] = $requestParamsResolver; - $clientEntityFactory = new ClientEntityFactory( $sspBridge, $helpers, @@ -284,13 +284,37 @@ public function __construct() ); $this->services[ClientRepository::class] = $clientRepository; + $pushedAuthorizationRequestEntityFactory = new PushedAuthorizationRequestEntityFactory( + $moduleConfig, + $helpers, + ); + $this->services[PushedAuthorizationRequestEntityFactory::class] = $pushedAuthorizationRequestEntityFactory; + $pushedAuthorizationRequestRepository = new PushedAuthorizationRequestRepository( $moduleConfig, $database, $protocolCache, + $pushedAuthorizationRequestEntityFactory, + $helpers, ); $this->services[PushedAuthorizationRequestRepository::class] = $pushedAuthorizationRequestRepository; + $requestObject = (new RequestObjectFactory($moduleConfig, $loggerService))->build(); + $this->services[RequestObject::class] = $requestObject; + + $requestParamsResolver = new RequestParamsResolver( + $helpers, + $core, + $federation, + $psrHttpBridge, + $requestObject, + $moduleConfig, + $clientRepository, + $pushedAuthorizationRequestRepository, + $loggerService, + ); + $this->services[RequestParamsResolver::class] = $requestParamsResolver; + $userEntityFactory = new UserEntityFactory($helpers); $this->services[UserEntityFactory::class] = $userEntityFactory; @@ -457,6 +481,13 @@ public function __construct() ), new ClientRedirectUriRule($requestParamsResolver, $helpers, $moduleConfig), new RequestObjectRule($requestParamsResolver, $helpers, $jwksResolver, $moduleConfig), + new RequestUriRule( + $requestParamsResolver, + $helpers, + $pushedAuthorizationRequestRepository, + $jwksResolver, + $moduleConfig, + ), new ResponseModeRule( $requestParamsResolver, $helpers, @@ -543,7 +574,6 @@ public function __construct() $refreshTokenIssuer, $helpers, $loggerService, - $pushedAuthorizationRequestRepository, ); $this->services[AuthCodeGrant::class] = $authCodeGrantFactory->build(); @@ -577,13 +607,9 @@ public function __construct() $refreshTokenIssuer, $helpers, $loggerService, - $pushedAuthorizationRequestRepository, ); $this->services[PreAuthCodeGrant::class] = $preAuthCodeGrantFactory->build(); - $requestObject = new RequestObject(); - $this->services[RequestObject::class] = $requestObject; - $authorizationServerFactory = new AuthorizationServerFactory( $moduleConfig, $clientRepository, @@ -597,10 +623,6 @@ public function __construct() $privateKey, $this->services[PreAuthCodeGrant::class], $loggerService, - $pushedAuthorizationRequestRepository, - $requestParamsResolver, - $jwksResolver, - $requestObject, ); $this->services[AuthorizationServer::class] = $authorizationServerFactory->build(); diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 291030c7..3580dece 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -23,6 +23,7 @@ use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Repositories\IssuerStateRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Repositories\RefreshTokenRepository; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Stores\Session\LogoutTicketStoreDb; @@ -747,9 +748,10 @@ private function version20260218163000(): void private function version20260608130000(): void { - $parTableName = $this->database->applyPrefix('oidc_par'); + $parTableName = $this->database->applyPrefix(PushedAuthorizationRequestRepository::TABLE_NAME); $clientTableName = $this->database->applyPrefix(ClientRepository::TABLE_NAME); $fkParClient = $this->generateIdentifierName([$parTableName, 'client_id'], 'fk'); + $idxParExpiresAt = $this->generateIdentifierName([$parTableName, 'expires_at'], 'idx'); $this->database->write(<<< EOT CREATE TABLE $parTableName ( @@ -764,7 +766,7 @@ private function version20260608130000(): void EOT ,); - $this->database->write("CREATE INDEX oidc_par_expires_at_idx ON $parTableName (expires_at)"); + $this->database->write("CREATE INDEX $idxParExpiresAt ON $parTableName (expires_at)"); } diff --git a/src/Services/ErrorResponder.php b/src/Services/ErrorResponder.php index 0b0cf348..e27d9cb7 100644 --- a/src/Services/ErrorResponder.php +++ b/src/Services/ErrorResponder.php @@ -45,4 +45,27 @@ public function forException(Throwable $exception): Response 500, ); } + + /** + * Create a JSON error response (as specified for the token endpoint), regardless of any redirect URI + * contained in the exception. This is appropriate for endpoints which must not redirect on errors, like + * the Pushed Authorization Request endpoint. + */ + public function forExceptionJson(OAuthServerException $exception): JsonResponse + { + $body = [ + 'error' => $exception->getErrorType(), + 'error_description' => $exception->getMessage(), + ]; + + if (($hint = $exception->getHint()) !== null) { + $body['hint'] = $hint; + } + + return new JsonResponse( + $body, + $exception->getHttpStatusCode(), + ['Cache-Control' => 'no-cache, no-store'], + ); + } } diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 36c585da..99bae8d8 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -78,6 +78,8 @@ private function initMetadata(): void ...$supportedSignatureAlgorithmNames, ]; $this->metadata[ClaimsEnum::RequestUriParameterSupported->value] = true; + // The https request_uri values must be pre-registered for the client (request_uris client metadata). + $this->metadata[ClaimsEnum::RequireRequestUriRegistration->value] = true; $this->metadata[ClaimsEnum::PushedAuthorizationRequestEndpoint->value] = $this->moduleConfig->getModuleUrl(RoutesEnum::PushedAuthorizationRequest->value); $this->metadata[ClaimsEnum::RequirePushedAuthorizationRequests->value] = diff --git a/src/Utils/AuthenticatedOAuth2ClientResolver.php b/src/Utils/AuthenticatedOAuth2ClientResolver.php index 62b174b3..88d48bec 100644 --- a/src/Utils/AuthenticatedOAuth2ClientResolver.php +++ b/src/Utils/AuthenticatedOAuth2ClientResolver.php @@ -330,9 +330,12 @@ public function forPrivateKeyJwt( // OpenID Core spec: The Audience SHOULD be the URL of the Authorization Server's Token Endpoint. // OpenID Federation spec: ...the audience of the signed JWT MUST be either the URL of the Authorization // Server's Authorization Endpoint or the Authorization Server's Entity Identifier. + // RFC 9126 (PAR): ...the authorization server MUST accept its issuer identifier, token endpoint URL, + // or pushed authorization request endpoint URL as values that identify it as an intended audience. $expectedAudience = [ $this->moduleConfig->getModuleUrl(RoutesEnum::Token->value), $this->moduleConfig->getModuleUrl(RoutesEnum::Authorization->value), + $this->moduleConfig->getModuleUrl(RoutesEnum::PushedAuthorizationRequest->value), $this->moduleConfig->getIssuer(), ]; diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index 09176946..c874322b 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -6,24 +6,54 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; +use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\RequestObject; +use SimpleSAML\OpenID\RequestObject\RequestObjectBag; use Symfony\Component\HttpFoundation\Request; /** * Resolve authorization params from an HTTP request (based or not based on - * a used method), and from Request Object param if present. + * a used method), from Request Object param if present, and from Request URI + * param (Pushed Authorization Request or Request Object by reference) if + * present. */ class RequestParamsResolver { + /** + * Resolved request_uri params, keyed by request_uri value. + * + * @var array + */ + protected array $resolvedRequestUriParams = []; + + /** + * Request Object Bags resolved from (fetched) https request_uri values, + * keyed by request_uri value. + * + * @var array + */ + protected array $resolvedRequestUriBags = []; + public function __construct( protected readonly Helpers $helpers, protected readonly Core $core, protected readonly Federation $federation, protected readonly PsrHttpBridge $psrHttpBridge, + protected readonly RequestObject $requestObject, + protected readonly ModuleConfig $moduleConfig, + protected readonly ClientRepository $clientRepository, + protected readonly PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, + protected readonly LoggerService $loggerService, ) { } @@ -74,6 +104,7 @@ public function getAll(Request|ServerRequestInterface $request): array return array_merge( $requestParams, $this->resolveRequestObjectParams($requestParams), + $this->resolveRequestUriParams($requestParams), ); } @@ -94,6 +125,7 @@ public function getAllBasedOnAllowedMethods( return array_merge( $requestParams, $this->resolveRequestObjectParams($requestParams), + $this->resolveRequestUriParams($requestParams), ); } @@ -175,6 +207,121 @@ protected function resolveRequestObjectParams(array $requestParams): array return []; } + /** + * Check if Request URI is present as a request param and resolve its + * claims to use them as params. For Pushed Authorization Request URIs + * (urn form), params are resolved from the previously pushed (validated) + * authorization request. For https Request URIs, the Request Object is + * fetched and parsed, but note that this won't do signature validation + * of it, nor any policy checks like one-time use or expiration. + * + * @see \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestUriRule + * @return mixed[] + */ + protected function resolveRequestUriParams(array $requestParams): array + { + if ( + (!array_key_exists(ParamsEnum::RequestUri->value, $requestParams)) || + (!is_string($requestUri = $requestParams[ParamsEnum::RequestUri->value])) || + ($requestUri === '') + ) { + return []; + } + + // Using both request and request_uri params is not allowed. Don't resolve anything and let the + // RequestUriRule produce the proper error. + if (array_key_exists(ParamsEnum::Request->value, $requestParams)) { + return []; + } + + if (array_key_exists($requestUri, $this->resolvedRequestUriParams)) { + return $this->resolvedRequestUriParams[$requestUri]; + } + + if (str_starts_with($requestUri, PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX)) { + return $this->resolvedRequestUriParams[$requestUri] = + $this->resolvePushedAuthorizationRequestParams($requestUri); + } + + if (str_starts_with(strtolower($requestUri), 'https://')) { + return $this->resolvedRequestUriParams[$requestUri] = + $this->resolveHttpsRequestUriParams($requestUri, $requestParams); + } + + return $this->resolvedRequestUriParams[$requestUri] = []; + } + + /** + * @return mixed[] + */ + protected function resolvePushedAuthorizationRequestParams(string $requestUri): array + { + try { + return $this->pushedAuthorizationRequestRepository->findValid($requestUri)?->getParameters() ?? []; + } catch (\Throwable $throwable) { + $this->loggerService->warning( + 'RequestParamsResolver: error resolving pushed authorization request: ' . $throwable->getMessage(), + compact('requestUri'), + ); + return []; + } + } + + /** + * Fetch the Request Object from the https Request URI and use its claims as params. The Request URI must be + * registered for the client resolved from the client_id request param (it is fetched only in that case). + * + * @return mixed[] + */ + protected function resolveHttpsRequestUriParams(string $requestUri, array $requestParams): array + { + $this->resolvedRequestUriBags[$requestUri] = null; + + if ( + (!array_key_exists(ParamsEnum::ClientId->value, $requestParams)) || + (!is_string($clientId = $requestParams[ParamsEnum::ClientId->value])) || + ($clientId === '') + ) { + return []; + } + + $client = $this->clientRepository->getClientEntity($clientId); + if ( + (!$client instanceof ClientEntityInterface) || + (!in_array($requestUri, $client->getRequestUris(), true)) + ) { + return []; + } + + try { + $requestObjectBag = $this->requestObject->requestObjectParser()->fromRequestUri( + $requestUri, + $this->moduleConfig->getRequestUriTimeout(), + $this->moduleConfig->getRequestUriMaxSizeBytes(), + ); + } catch (\Throwable $throwable) { + $this->loggerService->warning( + 'RequestParamsResolver: error fetching request object from request_uri: ' . $throwable->getMessage(), + compact('requestUri'), + ); + return []; + } + + $this->resolvedRequestUriBags[$requestUri] = $requestObjectBag; + + // Use the OpenID Connect Core flavor for (unverified) param resolution, since it is the most lenient + // one (signature validation and policy checks are handled in RequestUriRule). + return $requestObjectBag->get(Core\RequestObject::class)?->getPayload() ?? []; + } + + /** + * Get the Request Object Bag resolved from the given (fetched) https request_uri value, if any. + */ + public function getResolvedRequestUriBag(string $requestUri): ?RequestObjectBag + { + return $this->resolvedRequestUriBags[$requestUri] ?? null; + } + /** * Parse the Request Object token according to OpenID Core specification. * Note that this won't do signature validation of it. @@ -189,6 +336,18 @@ public function parseRequestObjectToken(string $token): Core\RequestObject return $this->core->requestObjectFactory()->fromToken($token); } + /** + * Parse the Request Object token using all available Request Object flavors (OpenID Connect Core, JAR, + * OpenID Federation). The returned bag contains an entry for every flavor for which the token parsed and + * passed flavor-specific validation, so it can be used to differentiate between, for example, OpenID + * Connect Core Request Objects (which can be unsigned) and JAR Request Objects (which must be signed). + * Note that this won't do signature validation. + */ + public function parseRequestObjectBag(string $token): RequestObjectBag + { + return $this->requestObject->requestObjectParser()->fromToken($token); + } + /** * Parse the Request Object token according to OpenID Federation * specification. Note that this won't do signature validation of it. diff --git a/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php index d983a0de..4f927740 100644 --- a/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php +++ b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php @@ -4,6 +4,11 @@ namespace SimpleSAML\Test\Module\oidc\unit\Controllers; +use DateTimeImmutable; +use DateTimeZone; +use League\OAuth2\Server\Exception\OAuthServerException; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseFactoryInterface; @@ -13,30 +18,34 @@ use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; use SimpleSAML\Module\oidc\Controllers\PushedAuthorizationController; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; +use SimpleSAML\Module\oidc\Entities\PushedAuthorizationRequestEntity; +use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; use SimpleSAML\Module\oidc\Helpers; -use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; +use SimpleSAML\Module\oidc\Server\RequestRules\Result; +use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; +use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Services\ErrorResponder; use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\AuthenticatedOAuth2ClientResolver; -use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\ValueAbstracts\ResolvedClientAuthenticationMethod; use SimpleSAML\OpenID\Codebooks\ClientAuthenticationMethodsEnum; -use SimpleSAML\OpenID\RequestObject; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; -/** - * @covers \SimpleSAML\Module\oidc\Controllers\PushedAuthorizationController - */ +#[CoversClass(PushedAuthorizationController::class)] +#[UsesClass(Result::class)] +#[UsesClass(ResultBag::class)] +#[UsesClass(ResolvedClientAuthenticationMethod::class)] class PushedAuthorizationControllerTest extends TestCase { protected MockObject $authenticatedOAuth2ClientResolverMock; protected MockObject $pushedAuthorizationRequestRepositoryMock; + protected MockObject $pushedAuthorizationRequestEntityFactoryMock; protected MockObject $requestRulesManagerMock; - protected MockObject $jwksResolverMock; - protected MockObject $requestObjectMock; - protected MockObject $moduleConfigMock; protected MockObject $psrHttpBridgeMock; protected MockObject $errorResponderMock; protected Helpers $helpers; @@ -46,6 +55,9 @@ class PushedAuthorizationControllerTest extends TestCase protected MockObject $responseMock; protected MockObject $responseFactoryMock; protected MockObject $streamMock; + protected MockObject $clientMock; + protected MockObject $parEntityMock; + protected MockObject $resultBagMock; protected function setUp(): void { @@ -53,10 +65,10 @@ protected function setUp(): void $this->pushedAuthorizationRequestRepositoryMock = $this->createMock( PushedAuthorizationRequestRepository::class, ); + $this->pushedAuthorizationRequestEntityFactoryMock = $this->createMock( + PushedAuthorizationRequestEntityFactory::class, + ); $this->requestRulesManagerMock = $this->createMock(RequestRulesManager::class); - $this->jwksResolverMock = $this->createMock(JwksResolver::class); - $this->requestObjectMock = $this->createMock(RequestObject::class); - $this->moduleConfigMock = $this->createMock(ModuleConfig::class); $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); $this->errorResponderMock = $this->createMock(ErrorResponder::class); $this->helpers = new Helpers(); @@ -72,6 +84,18 @@ protected function setUp(): void $this->responseMock->method('withHeader')->willReturn($this->responseMock); $this->responseFactoryMock->method('createResponse')->willReturn($this->responseMock); $this->psrHttpBridgeMock->method('getResponseFactory')->willReturn($this->responseFactoryMock); + + $this->clientMock = $this->createMock(ClientEntityInterface::class); + $this->clientMock->method('getIdentifier')->willReturn('client123'); + + $this->parEntityMock = $this->createMock(PushedAuthorizationRequestEntity::class); + $this->parEntityMock->method('getRequestUri') + ->willReturn(PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'); + $this->parEntityMock->method('getExpiresAt') + ->willReturn(new DateTimeImmutable('+5 minutes', new DateTimeZone('UTC'))); + + $this->resultBagMock = $this->createMock(ResultBag::class); + $this->requestRulesManagerMock->method('check')->willReturn($this->resultBagMock); } protected function sut(): PushedAuthorizationController @@ -79,10 +103,8 @@ protected function sut(): PushedAuthorizationController return new PushedAuthorizationController( $this->authenticatedOAuth2ClientResolverMock, $this->pushedAuthorizationRequestRepositoryMock, + $this->pushedAuthorizationRequestEntityFactoryMock, $this->requestRulesManagerMock, - $this->jwksResolverMock, - $this->requestObjectMock, - $this->moduleConfigMock, $this->psrHttpBridgeMock, $this->errorResponderMock, $this->helpers, @@ -90,6 +112,13 @@ protected function sut(): PushedAuthorizationController ); } + protected function prepareAuthenticatedClient( + ClientAuthenticationMethodsEnum $method = ClientAuthenticationMethodsEnum::ClientSecretPost, + ): void { + $resolvedAuth = new ResolvedClientAuthenticationMethod($this->clientMock, $method); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod')->willReturn($resolvedAuth); + } + public function testItIsInitializable(): void { $this->assertInstanceOf(PushedAuthorizationController::class, $this->sut()); @@ -117,17 +146,20 @@ public function testClientAuthenticationFailureThrows(): void $this->sut()->__invoke($this->serverRequestMock); } - public function testRejectsRequestUriInBody(): void + public function testConfidentialClientMustAuthenticate(): void { $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->clientMock->method('isConfidential')->willReturn(true); + $this->prepareAuthenticatedClient(ClientAuthenticationMethodsEnum::None); - $clientMock = $this->createMock(ClientEntityInterface::class); - $resolvedAuth = new ResolvedClientAuthenticationMethod( - $clientMock, - ClientAuthenticationMethodsEnum::ClientSecretPost, - ); - $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') - ->willReturn($resolvedAuth); + $this->expectException(OidcServerException::class); + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testRejectsRequestUriInBody(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->prepareAuthenticatedClient(); $this->serverRequestMock->method('getParsedBody')->willReturn([ 'request_uri' => 'some-uri', @@ -137,20 +169,27 @@ public function testRejectsRequestUriInBody(): void $this->sut()->__invoke($this->serverRequestMock); } - public function testHandlesValidParRequest(): void + public function testRejectsClientIdParamWhichDoesNotMatchAuthenticatedClient(): void { $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->prepareAuthenticatedClient(); - $clientMock = $this->createMock(ClientEntityInterface::class); - $clientMock->method('getIdentifier')->willReturn('client123'); + $this->serverRequestMock->method('getParsedBody')->willReturn([ + 'client_id' => 'otherClient', + ]); - $resolvedAuth = new ResolvedClientAuthenticationMethod( - $clientMock, - ClientAuthenticationMethodsEnum::ClientSecretPost, - ); - $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod')->willReturn($resolvedAuth); + $this->expectException(OidcServerException::class); + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testHandlesValidParRequest(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->prepareAuthenticatedClient(); $params = [ + 'client_id' => 'client123', + 'client_secret' => 'verysecret', 'redirect_uri' => 'https://localhost/callback', 'response_type' => 'code', 'scope' => 'openid', @@ -158,12 +197,24 @@ public function testHandlesValidParRequest(): void ]; $this->serverRequestMock->method('getParsedBody')->willReturn($params); - $this->serverRequestMock->method('withParsedBody')->willReturn($this->serverRequestMock); - $this->serverRequestMock->method('withQueryParams')->willReturn($this->serverRequestMock); - - $this->moduleConfigMock->method('getParRequestUriTtl')->willReturn(new \DateInterval('PT10M')); + // Client authentication params must not be persisted, while client_id is bound to the + // authenticated client. + $this->pushedAuthorizationRequestEntityFactoryMock->expects($this->once()) + ->method('buildNew') + ->with( + 'client123', + [ + 'client_id' => 'client123', + 'redirect_uri' => 'https://localhost/callback', + 'response_type' => 'code', + 'scope' => 'openid', + 'state' => 'xyz', + ], + ) + ->willReturn($this->parEntityMock); - $this->pushedAuthorizationRequestRepositoryMock->expects($this->once())->method('persist'); + $this->pushedAuthorizationRequestRepositoryMock->expects($this->once())->method('persist') + ->with($this->parEntityMock); $this->responseMock->expects($this->once())->method('withStatus') ->with(201)->willReturn($this->responseMock); @@ -171,4 +222,96 @@ public function testHandlesValidParRequest(): void $response = $this->sut()->__invoke($this->serverRequestMock); $this->assertSame($this->responseMock, $response); } + + public function testPersistsRequestObjectPayloadOnlyWhenJarIsUsed(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->prepareAuthenticatedClient(); + + $params = [ + 'request' => 'token', + 'client_secret' => 'verysecret', + 'some_stray_param' => 'value', + ]; + $this->serverRequestMock->method('getParsedBody')->willReturn($params); + + $requestObjectPayload = [ + 'client_id' => 'client123', + 'redirect_uri' => 'https://localhost/callback', + 'response_type' => 'code', + 'scope' => 'openid', + ]; + $requestObjectResult = new Result(RequestObjectRule::class, $requestObjectPayload); + $this->resultBagMock->method('get')->with(RequestObjectRule::class)->willReturn($requestObjectResult); + $this->resultBagMock->method('getOrFail')->with(RequestObjectRule::class)->willReturn($requestObjectResult); + + $this->pushedAuthorizationRequestEntityFactoryMock->expects($this->once()) + ->method('buildNew') + ->with('client123', $requestObjectPayload) + ->willReturn($this->parEntityMock); + + $this->pushedAuthorizationRequestRepositoryMock->expects($this->once())->method('persist'); + + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testRejectsRequestObjectClientIdClaimWhichDoesNotMatchAuthenticatedClient(): void + { + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->prepareAuthenticatedClient(); + + $this->serverRequestMock->method('getParsedBody')->willReturn(['request' => 'token']); + + $requestObjectResult = new Result(RequestObjectRule::class, ['client_id' => 'otherClient']); + $this->resultBagMock->method('get')->with(RequestObjectRule::class)->willReturn($requestObjectResult); + $this->resultBagMock->method('getOrFail')->with(RequestObjectRule::class)->willReturn($requestObjectResult); + + $this->expectException(OidcServerException::class); + $this->sut()->__invoke($this->serverRequestMock); + } + + public function testParReturnsJsonErrorResponseForOAuthServerException(): void + { + $requestMock = $this->createMock(Request::class); + $psrHttpFactoryMock = $this->createMock(PsrHttpFactory::class); + $psrHttpFactoryMock->method('createRequest')->willReturn($this->serverRequestMock); + $this->psrHttpBridgeMock->method('getPsrHttpFactory')->willReturn($psrHttpFactoryMock); + + // Make __invoke throw an OidcServerException (client authentication failure). + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod')->willReturn(null); + + $jsonResponse = new JsonResponse(); + $this->errorResponderMock->expects($this->once()) + ->method('forExceptionJson') + ->with($this->isInstanceOf(OAuthServerException::class)) + ->willReturn($jsonResponse); + + $this->assertSame($jsonResponse, $this->sut()->par($requestMock)); + } + + public function testParReturnsGenericJsonErrorResponseForUnexpectedThrowable(): void + { + $requestMock = $this->createMock(Request::class); + $psrHttpFactoryMock = $this->createMock(PsrHttpFactory::class); + $psrHttpFactoryMock->method('createRequest')->willReturn($this->serverRequestMock); + $this->psrHttpBridgeMock->method('getPsrHttpFactory')->willReturn($psrHttpFactoryMock); + + $this->serverRequestMock->method('getMethod')->willReturn('POST'); + $this->authenticatedOAuth2ClientResolverMock->method('forAnySupportedMethod') + ->willThrowException(new \RuntimeException('some internal error')); + + $jsonResponse = new JsonResponse(); + $this->errorResponderMock->expects($this->once()) + ->method('forExceptionJson') + ->with($this->callback( + // Internal error details must not leak to the client. + fn(OAuthServerException $exception): bool => + !str_contains($exception->getMessage(), 'some internal error') && + !str_contains((string)$exception->getHint(), 'some internal error'), + )) + ->willReturn($jsonResponse); + + $this->assertSame($jsonResponse, $this->sut()->par($requestMock)); + } } diff --git a/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php b/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php new file mode 100644 index 00000000..1b623094 --- /dev/null +++ b/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php @@ -0,0 +1,119 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getParRequestUriTtl')->willReturn(new DateInterval('PT5M')); + $this->helpers = new Helpers(); + } + + protected function sut(): PushedAuthorizationRequestEntityFactory + { + return new PushedAuthorizationRequestEntityFactory( + $this->moduleConfigMock, + $this->helpers, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(PushedAuthorizationRequestEntityFactory::class, $this->sut()); + } + + public function testCanBuildNew(): void + { + $parameters = ['client_id' => 'client123', 'response_type' => 'code']; + + $entity = $this->sut()->buildNew('client123', $parameters); + + $this->assertStringStartsWith( + PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX, + $entity->getRequestUri(), + ); + // Random part is 32 bytes, hex encoded. + $this->assertSame( + strlen(PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX) + 64, + strlen($entity->getRequestUri()), + ); + $this->assertSame('client123', $entity->getClientId()); + $this->assertSame($parameters, $entity->getParameters()); + $this->assertFalse($entity->isConsumed()); + + $expectedExpiresAt = $this->helpers->dateTime()->getUtc()->add(new DateInterval('PT5M')); + $this->assertEqualsWithDelta( + $expectedExpiresAt->getTimestamp(), + $entity->getExpiresAt()->getTimestamp(), + 5, + ); + } + + public function testBuildNewGeneratesUniqueRequestUris(): void + { + $sut = $this->sut(); + + $this->assertNotSame( + $sut->buildNew('client123', [])->getRequestUri(), + $sut->buildNew('client123', [])->getRequestUri(), + ); + } + + public function testCanBuildFromState(): void + { + $entity = $this->sut()->fromState([ + 'request_uri' => PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123', + 'client_id' => 'client123', + 'parameters' => '{"response_type":"code"}', + 'expires_at' => '2026-01-01 12:00:00', + 'is_consumed' => 0, + ]); + + $this->assertSame( + PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123', + $entity->getRequestUri(), + ); + $this->assertSame('client123', $entity->getClientId()); + $this->assertSame(['response_type' => 'code'], $entity->getParameters()); + $this->assertFalse($entity->isConsumed()); + + // Stored datetimes are interpreted as UTC. + $this->assertSame( + '2026-01-01 12:00:00', + $entity->getExpiresAt()->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'), + ); + } + + public function testFromStateThrowsForInvalidState(): void + { + $this->expectException(OpenIdException::class); + + $this->sut()->fromState([ + 'request_uri' => 123, + 'client_id' => 'client123', + 'parameters' => '{}', + 'expires_at' => '2026-01-01 12:00:00', + ]); + } +} diff --git a/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php b/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php new file mode 100644 index 00000000..52ad7803 --- /dev/null +++ b/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php @@ -0,0 +1,169 @@ + 'sqlite::memory:', + 'database.username' => null, + 'database.password' => null, + 'database.prefix' => 'phpunit_', + 'database.persistent' => true, + 'database.secondaries' => [], + ]; + + Configuration::loadFromArray($config, '', 'simplesaml'); + (new DatabaseMigration())->migrate(); + } + + protected function setUp(): void + { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getParRequestUriTtl')->willReturn(new DateInterval('PT5M')); + $this->helpers = new Helpers(); + $this->entityFactory = new PushedAuthorizationRequestEntityFactory( + $this->moduleConfigMock, + $this->helpers, + ); + + $this->repository = new PushedAuthorizationRequestRepository( + $this->moduleConfigMock, + Database::getInstance(), + null, + $this->entityFactory, + $this->helpers, + ); + } + + public function testGetTableName(): void + { + $this->assertSame('phpunit_oidc_par', $this->repository->getTableName()); + } + + public function testCanPersistAndFind(): void + { + $parameters = ['client_id' => 'client123', 'response_type' => 'code']; + $entity = $this->entityFactory->buildNew('client123', $parameters); + + $this->repository->persist($entity); + + $foundEntity = $this->repository->find($entity->getRequestUri()); + + $this->assertInstanceOf(PushedAuthorizationRequestEntity::class, $foundEntity); + $this->assertSame($entity->getRequestUri(), $foundEntity->getRequestUri()); + $this->assertSame('client123', $foundEntity->getClientId()); + $this->assertSame($parameters, $foundEntity->getParameters()); + $this->assertSame( + $entity->getExpiresAt()->getTimestamp(), + $foundEntity->getExpiresAt()->getTimestamp(), + ); + $this->assertFalse($foundEntity->isConsumed()); + } + + public function testFindReturnsNullForUnknownRequestUri(): void + { + $this->assertNull( + $this->repository->find(PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'unknown'), + ); + } + + public function testFindValidReturnsEntityForValidRequestUri(): void + { + $entity = $this->entityFactory->buildNew('client123', []); + $this->repository->persist($entity); + + $this->assertInstanceOf( + PushedAuthorizationRequestEntity::class, + $this->repository->findValid($entity->getRequestUri()), + ); + } + + public function testFindValidReturnsNullForExpiredRequestUri(): void + { + $entity = $this->entityFactory->buildNew( + 'client123', + [], + $this->helpers->dateTime()->getUtc()->sub(new DateInterval('PT1M')), + ); + $this->repository->persist($entity); + + $this->assertNull($this->repository->findValid($entity->getRequestUri())); + } + + public function testFindValidReturnsNullForConsumedRequestUri(): void + { + $entity = $this->entityFactory->buildNew('client123', []); + $this->repository->persist($entity); + + $this->assertTrue($this->repository->consume($entity->getRequestUri())); + + $this->assertNull($this->repository->findValid($entity->getRequestUri())); + } + + public function testConsumeReturnsTrueOnlyOnce(): void + { + $entity = $this->entityFactory->buildNew('client123', []); + $this->repository->persist($entity); + + $this->assertTrue($this->repository->consume($entity->getRequestUri())); + // Already consumed, so it can act as an atomic replay guard. + $this->assertFalse($this->repository->consume($entity->getRequestUri())); + } + + public function testConsumeReturnsFalseForUnknownRequestUri(): void + { + $this->assertFalse( + $this->repository->consume(PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'unknown'), + ); + } + + public function testCanRemoveExpired(): void + { + $expiredEntity = $this->entityFactory->buildNew( + 'client123', + [], + $this->helpers->dateTime()->getUtc()->sub(new DateInterval('PT1M')), + ); + $this->repository->persist($expiredEntity); + $validEntity = $this->entityFactory->buildNew('client123', []); + $this->repository->persist($validEntity); + + $this->repository->removeExpired(); + + $this->assertNull($this->repository->find($expiredEntity->getRequestUri())); + $this->assertInstanceOf( + PushedAuthorizationRequestEntity::class, + $this->repository->find($validEntity->getRequestUri()), + ); + } +} diff --git a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php index 52a187d1..d991c6a1 100644 --- a/tests/unit/src/Server/Grants/AuthCodeGrantTest.php +++ b/tests/unit/src/Server/Grants/AuthCodeGrantTest.php @@ -10,10 +10,10 @@ use SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\AuthCodeEntityFactory; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\Interfaces\AccessTokenRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\AuthCodeRepositoryInterface; use SimpleSAML\Module\oidc\Repositories\Interfaces\RefreshTokenRepositoryInterface; -use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Server\Grants\AuthCodeGrant; use SimpleSAML\Module\oidc\Server\RequestRules\RequestRulesManager; use SimpleSAML\Module\oidc\Server\TokenIssuers\RefreshTokenIssuer; @@ -30,7 +30,7 @@ class AuthCodeGrantTest extends TestCase protected Stub $refreshTokenRepositoryStub; protected DateInterval $authCodeTtl; protected Stub $requestRulesManagerStub; - protected Stub $pushedAuthorizationRequestRepositoryStub; + protected Stub $moduleConfigStub; protected Stub $requestParamsResolverStub; protected Stub $accessTokenEntityFactoryStub; protected Stub $authCodeEntityFactoryStub; @@ -48,9 +48,7 @@ protected function setUp(): void $this->refreshTokenRepositoryStub = $this->createStub(RefreshTokenRepositoryInterface::class); $this->authCodeTtl = new DateInterval('PT1M'); $this->requestRulesManagerStub = $this->createStub(RequestRulesManager::class); - $this->pushedAuthorizationRequestRepositoryStub = $this->createStub( - PushedAuthorizationRequestRepository::class, - ); + $this->moduleConfigStub = $this->createStub(ModuleConfig::class); $this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class); $this->accessTokenEntityFactoryStub = $this->createStub(AccessTokenEntityFactory::class); $this->authCodeEntityFactoryStub = $this->createStub(AuthcodeEntityFactory::class); @@ -78,7 +76,7 @@ public function testCanCreateInstance(): void $this->refreshTokenIssuerStub, $this->helpersStub, $this->loggerMock, - $this->pushedAuthorizationRequestRepositoryStub, + $this->moduleConfigStub, ), ); } diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php index f044b826..e1b0755a 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php @@ -23,6 +23,8 @@ use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Core\RequestObject; +use SimpleSAML\OpenID\Jar\RequestObject as JarRequestObject; +use SimpleSAML\OpenID\RequestObject\RequestObjectBag; #[CoversClass(RequestObjectRule::class)] class RequestObjectRuleTest extends TestCase @@ -31,6 +33,8 @@ class RequestObjectRuleTest extends TestCase protected Stub $resultBagStub; protected MockObject $requestParamsResolverMock; protected MockObject $requestObjectMock; + protected MockObject $jarRequestObjectMock; + protected MockObject $requestObjectBagMock; protected Stub $requestStub; protected Stub $loggerServiceStub; protected MockObject $jwksResolverMock; @@ -41,6 +45,7 @@ class RequestObjectRuleTest extends TestCase protected function setUp(): void { $this->clientStub = $this->createMock(ClientEntityInterface::class); + $this->clientStub->method('getIdentifier')->willReturn('client123'); $this->resultBagStub = $this->createStub(ResultBag::class); $this->resultBagStub->method('getOrFail')->willReturnMap([ [ClientRule::class, new Result(ClientRule::class, $this->clientStub)], @@ -49,6 +54,9 @@ protected function setUp(): void $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); $this->requestObjectMock = $this->createMock(RequestObject::class); $this->requestObjectMock->method('getPayload')->willReturn(['payload']); + $this->jarRequestObjectMock = $this->createMock(JarRequestObject::class); + $this->jarRequestObjectMock->method('getPayload')->willReturn(['payload']); + $this->requestObjectBagMock = $this->createMock(RequestObjectBag::class); $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->loggerServiceStub = $this->createStub(LoggerService::class); $this->jwksResolverMock = $this->createMock(JwksResolver::class); @@ -76,6 +84,33 @@ protected function sut( ); } + protected function prepareOidcRequest(): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + // OpenID Connect request is designated by the openid scope. + $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('openid'); + $this->requestObjectBagMock->method('get') + ->willReturnMap([ + [RequestObject::class, $this->requestObjectMock], + ]); + $this->requestParamsResolverMock->method('parseRequestObjectBag') + ->with('token')->willReturn($this->requestObjectBagMock); + } + + protected function prepareOAuth2Request(?JarRequestObject $jarRequestObject = null): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + // No openid scope, so this is a plain OAuth 2.0 request (JAR rules apply). + $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('profile'); + $this->requestObjectBagMock->method('get') + ->willReturnMap([ + [RequestObject::class, $this->requestObjectMock], + [JarRequestObject::class, $jarRequestObject], + ]); + $this->requestParamsResolverMock->method('parseRequestObjectBag') + ->with('token')->willReturn($this->requestObjectBagMock); + } + public function testCanCreateInstance(): void { $this->assertInstanceOf(RequestObjectRule::class, $this->sut()); @@ -93,12 +128,10 @@ public function testRequestParamCanBeAbsent(): void $this->assertNull($result); } - public function testUnprotectedRequestParamCanBeUsed(): void + public function testUnprotectedRequestParamCanBeUsedForOidcRequest(): void { - $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->prepareOidcRequest(); $this->requestObjectMock->method('isProtected')->willReturn(false); - $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') - ->with('token')->willReturn($this->requestObjectMock); $result = $this->sut()->checkRule( $this->requestStub, @@ -114,10 +147,8 @@ public function testUnprotectedRequestParamCanBeUsed(): void public function testMissingClientJwksThrows(): void { - $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->prepareOidcRequest(); $this->requestObjectMock->method('isProtected')->willReturn(true); - $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') - ->with('token')->willReturn($this->requestObjectMock); $this->jwksResolverMock->expects($this->once())->method('forClient') ->with($this->clientStub)->willReturn(null); @@ -133,12 +164,10 @@ public function testMissingClientJwksThrows(): void public function testThrowsForInvalidRequestObject(): void { - $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->prepareOidcRequest(); $this->requestObjectMock->method('isProtected')->willReturn(true); $this->requestObjectMock->expects($this->once())->method('verifyWithKeySet')->with(['jwks']) ->willThrowException(OidcServerException::accessDenied()); - $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') - ->with('token')->willReturn($this->requestObjectMock); $this->jwksResolverMock->expects($this->once())->method('forClient') ->with($this->clientStub) ->willReturn(['jwks']); @@ -155,11 +184,9 @@ public function testThrowsForInvalidRequestObject(): void public function testReturnsValidRequestObject(): void { - $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->prepareOidcRequest(); $this->requestObjectMock->method('isProtected')->willReturn(true); $this->requestObjectMock->expects($this->once())->method('verifyWithKeySet')->with(['jwks']); - $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') - ->with('token')->willReturn($this->requestObjectMock); $this->jwksResolverMock->expects($this->once()) ->method('forClient') @@ -181,10 +208,8 @@ public function testReturnsValidRequestObject(): void public function testThrowsWhenGlobalRequireSignedRequestObjectIsEnabled(): void { - $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->prepareOidcRequest(); $this->requestObjectMock->method('isProtected')->willReturn(false); - $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') - ->with('token')->willReturn($this->requestObjectMock); $this->moduleConfigStub->method('getRequireSignedRequestObject')->willReturn(true); @@ -201,10 +226,8 @@ public function testThrowsWhenGlobalRequireSignedRequestObjectIsEnabled(): void public function testThrowsWhenClientRequireSignedRequestObjectIsEnabled(): void { - $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->prepareOidcRequest(); $this->requestObjectMock->method('isProtected')->willReturn(false); - $this->requestParamsResolverMock->expects($this->once())->method('parseRequestObjectToken') - ->with('token')->willReturn($this->requestObjectMock); $this->moduleConfigStub->method('getRequireSignedRequestObject')->willReturn(false); $this->clientStub->method('getRequireSignedRequestObject')->willReturn(true); @@ -219,4 +242,60 @@ public function testThrowsWhenClientRequireSignedRequestObjectIsEnabled(): void $this->responseModeStub, ); } + + public function testThrowsForOAuth2RequestWithNonJarRequestObject(): void + { + // For example, an unsigned Request Object is not a valid JAR Request Object. + $this->prepareOAuth2Request(null); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testThrowsForOAuth2RequestWithMismatchedClientIdClaim(): void + { + $this->jarRequestObjectMock->method('getClientId')->willReturn('otherClient'); + $this->prepareOAuth2Request($this->jarRequestObjectMock); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testReturnsValidJarRequestObjectForOAuth2Request(): void + { + $this->jarRequestObjectMock->method('getClientId')->willReturn('client123'); + $this->jarRequestObjectMock->expects($this->once())->method('verifyWithKeySet')->with(['jwks']); + $this->prepareOAuth2Request($this->jarRequestObjectMock); + + $this->jwksResolverMock->expects($this->once()) + ->method('forClient') + ->with($this->clientStub) + ->willReturn(['jwks']); + + $result = $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + + $this->assertInstanceOf(Result::class, $result); + $this->assertIsArray($result->getValue()); + $this->assertNotEmpty($result->getValue()); + } } diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php new file mode 100644 index 00000000..aa5f0608 --- /dev/null +++ b/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php @@ -0,0 +1,370 @@ +clientMock = $this->createMock(ClientEntityInterface::class); + $this->clientMock->method('getIdentifier')->willReturn('client123'); + $this->resultBagMock = $this->createMock(ResultBag::class); + $this->resultBagMock->method('getOrFail')->willReturnMap([ + [ClientRule::class, new Result(ClientRule::class, $this->clientMock)], + ]); + $this->requestParamsResolverMock = $this->createMock(RequestParamsResolver::class); + $this->pushedAuthorizationRequestRepositoryMock = $this->createMock( + PushedAuthorizationRequestRepository::class, + ); + $this->jwksResolverMock = $this->createMock(JwksResolver::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->parEntityMock = $this->createMock(PushedAuthorizationRequestEntity::class); + $this->requestStub = $this->createStub(ServerRequestInterface::class); + $this->loggerServiceMock = $this->createMock(LoggerService::class); + $this->helpers = new Helpers(); + $this->responseModeStub = $this->createStub(ResponseModeInterface::class); + } + + protected function sut(): RequestUriRule + { + return new RequestUriRule( + $this->requestParamsResolverMock, + $this->helpers, + $this->pushedAuthorizationRequestRepositoryMock, + $this->jwksResolverMock, + $this->moduleConfigMock, + ); + } + + /** + * Set raw request params which will be resolved from the request itself (not the merged view). + * + * @param array $params + */ + protected function prepareRawParams(array $params): void + { + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods') + ->willReturnCallback(fn(string $paramKey): ?string => $params[$paramKey] ?? null); + } + + protected function checkRule(): mixed + { + return $this->sut()->checkRule( + $this->requestStub, + $this->resultBagMock, + $this->loggerServiceMock, + [], + $this->responseModeStub, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(RequestUriRule::class, $this->sut()); + } + + public function testRequestUriParamCanBeAbsent(): void + { + $this->prepareRawParams([]); + + $this->assertNull($this->checkRule()); + } + + public function testThrowsIfParIsRequiredGloballyButNotUsed(): void + { + $this->prepareRawParams([]); + $this->moduleConfigMock->method('getRequirePushedAuthorizationRequests')->willReturn(true); + + $this->expectException(OidcServerException::class); + $this->expectExceptionMessageMatches('/invalid/i'); + $this->checkRule(); + } + + public function testThrowsIfParIsRequiredForClientButNotUsed(): void + { + $this->prepareRawParams([]); + $this->moduleConfigMock->method('getRequirePushedAuthorizationRequests')->willReturn(false); + $this->clientMock->method('getRequirePushedAuthorizationRequests')->willReturn(true); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfRequestAndRequestUriAreBothPresent(): void + { + $this->prepareRawParams([ + 'request_uri' => self::PAR_REQUEST_URI, + 'request' => 'token', + 'client_id' => 'client123', + ]); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfClientIdParamIsMissing(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI]); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsForInvalidRequestUriScheme(): void + { + $this->prepareRawParams(['request_uri' => 'urn:other:thing', 'client_id' => 'client123']); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfPushedAuthorizationRequestIsNotFound(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn(null); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfPushedAuthorizationRequestIsExpired(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->parEntityMock->method('isExpired')->willReturn(true); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn($this->parEntityMock); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfPushedAuthorizationRequestIsConsumed(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->parEntityMock->method('isExpired')->willReturn(false); + $this->parEntityMock->method('isConsumed')->willReturn(true); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn($this->parEntityMock); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfPushedAuthorizationRequestIsBoundToDifferentClient(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->parEntityMock->method('isExpired')->willReturn(false); + $this->parEntityMock->method('isConsumed')->willReturn(false); + $this->parEntityMock->method('getClientId')->willReturn('otherClient'); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn($this->parEntityMock); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsIfPushedAuthorizationRequestConsumptionFails(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->parEntityMock->method('isExpired')->willReturn(false); + $this->parEntityMock->method('isConsumed')->willReturn(false); + $this->parEntityMock->method('getClientId')->willReturn('client123'); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn($this->parEntityMock); + $this->pushedAuthorizationRequestRepositoryMock->method('consume')->willReturn(false); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testCanUseValidPushedAuthorizationRequestUri(): void + { + $this->prepareRawParams(['request_uri' => self::PAR_REQUEST_URI, 'client_id' => 'client123']); + $this->parEntityMock->method('isExpired')->willReturn(false); + $this->parEntityMock->method('isConsumed')->willReturn(false); + $this->parEntityMock->method('getClientId')->willReturn('client123'); + $this->pushedAuthorizationRequestRepositoryMock->method('find')->willReturn($this->parEntityMock); + // Request URI is consumed at validation time (one-time use). + $this->pushedAuthorizationRequestRepositoryMock->expects($this->once()) + ->method('consume') + ->with(self::PAR_REQUEST_URI) + ->willReturn(true); + + $result = $this->checkRule(); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(self::PAR_REQUEST_URI, $result->getValue()); + } + + public function testThrowsForHttpsRequestUriIfParIsRequired(): void + { + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + $this->moduleConfigMock->method('getRequirePushedAuthorizationRequests')->willReturn(true); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsForUnregisteredHttpsRequestUri(): void + { + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + $this->clientMock->method('getRequestUris')->willReturn(['https://client.example.org/other.jwt']); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testThrowsForUnresolvableHttpsRequestUri(): void + { + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); + $this->requestParamsResolverMock->method('getResolvedRequestUriBag')->willReturn(null); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + /** + * @param array $bagContent + */ + protected function prepareRequestObjectBag(array $bagContent): void + { + $requestObjectBagMock = $this->createMock(RequestObjectBag::class); + $requestObjectBagMock->method('get') + ->willReturnCallback(fn(string $class): ?object => $bagContent[$class] ?? null); + $this->requestParamsResolverMock->method('getResolvedRequestUriBag') + ->with(self::HTTPS_REQUEST_URI) + ->willReturn($requestObjectBagMock); + } + + public function testThrowsForOAuth2RequestIfFetchedRequestObjectIsNotJar(): void + { + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); + // No openid scope, so this is a plain OAuth 2.0 request (JAR rules apply). For example, an + // unsigned Request Object is not a valid JAR Request Object. + $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('profile'); + $connectRequestObjectMock = $this->createMock(RequestObject::class); + $this->prepareRequestObjectBag([ + RequestObject::class => $connectRequestObjectMock, + JarRequestObject::class => null, + ]); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testCanUseFetchedJarRequestObjectForOAuth2Request(): void + { + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); + $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('profile'); + + $jarRequestObjectMock = $this->createMock(JarRequestObject::class); + $jarRequestObjectMock->method('getPayload')->willReturn(['client_id' => 'client123']); + $jarRequestObjectMock->expects($this->once())->method('verifyWithKeySet')->with(['jwks']); + $this->prepareRequestObjectBag([JarRequestObject::class => $jarRequestObjectMock]); + + $this->jwksResolverMock->method('forClient')->with($this->clientMock)->willReturn(['jwks']); + + // The validated Request Object payload is marked as resolved for other rules. + $this->resultBagMock->expects($this->once())->method('add') + ->with($this->callback( + fn(Result $result): bool => $result->getKey() === RequestObjectRule::class, + )); + + $result = $this->checkRule(); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(self::HTTPS_REQUEST_URI, $result->getValue()); + } + + public function testThrowsForOidcRequestIfUnsignedRequestObjectIsFetchedButSignatureIsRequired(): void + { + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); + // OpenID Connect request is designated by the openid scope. + $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('openid'); + $this->moduleConfigMock->method('getRequireSignedRequestObject')->willReturn(true); + + $connectRequestObjectMock = $this->createMock(RequestObject::class); + $connectRequestObjectMock->method('isProtected')->willReturn(false); + $this->prepareRequestObjectBag([RequestObject::class => $connectRequestObjectMock]); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } + + public function testCanUseFetchedUnsignedRequestObjectForOidcRequest(): void + { + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); + $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('openid'); + + $connectRequestObjectMock = $this->createMock(RequestObject::class); + $connectRequestObjectMock->method('isProtected')->willReturn(false); + $connectRequestObjectMock->method('getPayload')->willReturn(['client_id' => 'client123']); + $this->prepareRequestObjectBag([RequestObject::class => $connectRequestObjectMock]); + + $result = $this->checkRule(); + + $this->assertInstanceOf(Result::class, $result); + $this->assertSame(self::HTTPS_REQUEST_URI, $result->getValue()); + } + + public function testThrowsIfFetchedRequestObjectClientIdClaimDoesNotMatch(): void + { + $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); + $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); + $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('openid'); + + $connectRequestObjectMock = $this->createMock(RequestObject::class); + $connectRequestObjectMock->method('isProtected')->willReturn(false); + $connectRequestObjectMock->method('getPayload')->willReturn(['client_id' => 'otherClient']); + $this->prepareRequestObjectBag([RequestObject::class => $connectRequestObjectMock]); + + $this->expectException(OidcServerException::class); + $this->checkRule(); + } +} diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index 68eba186..ea889863 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -139,6 +139,7 @@ public function testItReturnsExpectedMetadata(): void 'request_parameter_supported' => true, 'request_object_signing_alg_values_supported' => ['none', 'RS256'], 'request_uri_parameter_supported' => true, + 'require_request_uri_registration' => true, 'pushed_authorization_request_endpoint' => 'http://localhost/par', 'require_pushed_authorization_requests' => false, 'grant_types_supported' => ['authorization_code', 'refresh_token'], diff --git a/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php index b788ec7c..2557c747 100644 --- a/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php +++ b/tests/unit/src/Utils/AuthenticatedOAuth2ClientResolverTest.php @@ -67,6 +67,7 @@ protected function setUp(): void ->willReturnMap([ [RoutesEnum::Token->value, self::TOKEN_ENDPOINT], [RoutesEnum::Authorization->value, 'https://example.org/oidc/authorization.php'], + [RoutesEnum::PushedAuthorizationRequest->value, 'https://example.org/oidc/par'], ]); $this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER); $this->dateTimeHelperMock = $this->createMock(Helpers\DateTime::class); diff --git a/tests/unit/src/Utils/RequestParamsResolverTest.php b/tests/unit/src/Utils/RequestParamsResolverTest.php index a183bf91..4fefa17e 100644 --- a/tests/unit/src/Utils/RequestParamsResolverTest.php +++ b/tests/unit/src/Utils/RequestParamsResolverTest.php @@ -5,19 +5,31 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; +use SimpleSAML\Module\oidc\Entities\PushedAuthorizationRequestEntity; +use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; use SimpleSAML\Module\oidc\Helpers; +use SimpleSAML\Module\oidc\ModuleConfig; +use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; +use SimpleSAML\Module\oidc\Services\LoggerService; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Core\Factories\RequestObjectFactory; use SimpleSAML\OpenID\Core\RequestObject; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\RequestObject as RequestObjectFacade; +use SimpleSAML\OpenID\RequestObject\RequestObjectBag; +use SimpleSAML\OpenID\RequestObject\RequestObjectParser; #[CoversClass(RequestParamsResolver::class)] +#[UsesClass(RequestObjectBag::class)] class RequestParamsResolverTest extends TestCase { protected MockObject $helpersMock; @@ -28,6 +40,12 @@ class RequestParamsResolverTest extends TestCase protected MockObject $requestObjectFactoryMock; protected MockObject $federationMock; protected MockObject $psrHttpBridgeMock; + protected MockObject $requestObjectFacadeMock; + protected MockObject $requestObjectParserMock; + protected MockObject $moduleConfigMock; + protected MockObject $clientRepositoryMock; + protected MockObject $pushedAuthorizationRequestRepositoryMock; + protected MockObject $loggerServiceMock; protected array $queryParams = [ 'a' => 'b', @@ -57,6 +75,16 @@ protected function setUp(): void $this->coreMock->method('requestObjectFactory')->willReturn($this->requestObjectFactoryMock); $this->federationMock = $this->createMock(Federation::class); $this->psrHttpBridgeMock = $this->createMock(PsrHttpBridge::class); + $this->requestObjectParserMock = $this->createMock(RequestObjectParser::class); + $this->requestObjectFacadeMock = $this->createMock(RequestObjectFacade::class); + $this->requestObjectFacadeMock->method('requestObjectParser') + ->willReturn($this->requestObjectParserMock); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->clientRepositoryMock = $this->createMock(ClientRepository::class); + $this->pushedAuthorizationRequestRepositoryMock = $this->createMock( + PushedAuthorizationRequestRepository::class, + ); + $this->loggerServiceMock = $this->createMock(LoggerService::class); } protected function mock( @@ -70,7 +98,17 @@ protected function mock( $federationMock ??= $this->federationMock; $psrHttpBridgeMock ??= $this->psrHttpBridgeMock; - return new RequestParamsResolver($helpersMock, $coreMock, $federationMock, $psrHttpBridgeMock); + return new RequestParamsResolver( + $helpersMock, + $coreMock, + $federationMock, + $psrHttpBridgeMock, + $this->requestObjectFacadeMock, + $this->moduleConfigMock, + $this->clientRepositoryMock, + $this->pushedAuthorizationRequestRepositoryMock, + $this->loggerServiceMock, + ); } public function testCanCreateInstance(): void @@ -160,4 +198,170 @@ public function testCanGetFromRequestBasedOnAllowedMethods(): void $this->mock()->getFromRequestBasedOnAllowedMethods('a', $this->requestMock), ); } + + public function testCanGetAllWithPushedAuthorizationRequestUri(): void + { + $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri]; + + $httpHelperMock = $this->createMock(Helpers\Http::class); + $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); + $helpersMock = $this->createMock(Helpers::class); + $helpersMock->method('http')->willReturn($httpHelperMock); + + $parEntityMock = $this->createMock(PushedAuthorizationRequestEntity::class); + $parEntityMock->method('getParameters')->willReturn($this->requestObjectParams); + + $this->pushedAuthorizationRequestRepositoryMock->expects($this->once()) + ->method('findValid') + ->with($requestUri) + ->willReturn($parEntityMock); + + $sut = $this->mock($helpersMock); + + $this->assertSame( + array_merge($queryParams, $this->requestObjectParams), + $sut->getAll($this->requestMock), + ); + + // Resolution is memoized, so the repository is not queried again. + $this->assertSame( + array_merge($queryParams, $this->requestObjectParams), + $sut->getAll($this->requestMock), + ); + } + + public function testGetAllResolvesNothingForInvalidPushedAuthorizationRequestUri(): void + { + $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri]; + + $httpHelperMock = $this->createMock(Helpers\Http::class); + $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); + $helpersMock = $this->createMock(Helpers::class); + $helpersMock->method('http')->willReturn($httpHelperMock); + + $this->pushedAuthorizationRequestRepositoryMock->method('findValid')->willReturn(null); + + $this->assertSame( + $queryParams, + $this->mock($helpersMock)->getAll($this->requestMock), + ); + } + + public function testGetAllSkipsRequestUriResolutionIfRequestParamIsAlsoPresent(): void + { + $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'request' => 'token']; + + $httpHelperMock = $this->createMock(Helpers\Http::class); + $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); + $helpersMock = $this->createMock(Helpers::class); + $helpersMock->method('http')->willReturn($httpHelperMock); + + $this->pushedAuthorizationRequestRepositoryMock->expects($this->never())->method('findValid'); + + $this->mock($helpersMock)->getAll($this->requestMock); + } + + public function testCanGetAllWithHttpsRequestUriForRegisteredUri(): void + { + $requestUri = 'https://client.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'client123']; + + $httpHelperMock = $this->createMock(Helpers\Http::class); + $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); + $helpersMock = $this->createMock(Helpers::class); + $helpersMock->method('http')->willReturn($httpHelperMock); + + $clientEntityMock = $this->createMock(ClientEntityInterface::class); + $clientEntityMock->method('getRequestUris')->willReturn([$requestUri]); + $this->clientRepositoryMock->method('getClientEntity') + ->with('client123') + ->willReturn($clientEntityMock); + + $requestObjectBag = $this->createMock(RequestObjectBag::class); + $requestObjectBag->method('get') + ->willReturnMap([ + [RequestObject::class, $this->requestObjectMock], + ]); + + $this->requestObjectParserMock->expects($this->once()) + ->method('fromRequestUri') + ->with($requestUri) + ->willReturn($requestObjectBag); + + $sut = $this->mock($helpersMock); + + $this->assertSame( + array_merge($queryParams, $this->requestObjectParams), + $sut->getAll($this->requestMock), + ); + + // Resolution is memoized, so the Request Object is not fetched again. + $sut->getAll($this->requestMock); + + $this->assertSame( + $requestObjectBag, + $sut->getResolvedRequestUriBag($requestUri), + ); + } + + public function testGetAllDoesNotFetchHttpsRequestUriIfNotRegisteredForClient(): void + { + $requestUri = 'https://client.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'client123']; + + $httpHelperMock = $this->createMock(Helpers\Http::class); + $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); + $helpersMock = $this->createMock(Helpers::class); + $helpersMock->method('http')->willReturn($httpHelperMock); + + $clientEntityMock = $this->createMock(ClientEntityInterface::class); + $clientEntityMock->method('getRequestUris')->willReturn(['https://client.example.org/other.jwt']); + $this->clientRepositoryMock->method('getClientEntity')->willReturn($clientEntityMock); + + $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); + + $sut = $this->mock($helpersMock); + + $this->assertSame( + $queryParams, + $sut->getAll($this->requestMock), + ); + + $this->assertNull($sut->getResolvedRequestUriBag($requestUri)); + } + + public function testGetAllDoesNotFetchHttpsRequestUriIfClientIdParamIsMissing(): void + { + $requestUri = 'https://client.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri]; + + $httpHelperMock = $this->createMock(Helpers\Http::class); + $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); + $helpersMock = $this->createMock(Helpers::class); + $helpersMock->method('http')->willReturn($httpHelperMock); + + $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); + + $this->assertSame( + $queryParams, + $this->mock($helpersMock)->getAll($this->requestMock), + ); + } + + public function testCanParseRequestObjectBag(): void + { + $requestObjectBag = new RequestObjectBag(); + $this->requestObjectParserMock->expects($this->once()) + ->method('fromToken') + ->with('token') + ->willReturn($requestObjectBag); + + $this->assertSame( + $requestObjectBag, + $this->mock()->parseRequestObjectBag('token'), + ); + } } From fa92f996e2f0cb6c0d78648c8131569495aefbef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 11 Jun 2026 17:23:47 +0200 Subject: [PATCH 04/11] WIP --- config/module_oidc.php.dist | 3 ++- .../PushedAuthorizationController.php | 13 ++--------- .../RequestRules/Rules/AbstractRule.php | 6 +++-- .../RequestRules/Rules/RequestObjectRule.php | 23 +++++++++++-------- .../RequestRules/Rules/RequestUriRule.php | 15 +++++++----- src/Utils/RequestParamsResolver.php | 16 +++++++------ 6 files changed, 40 insertions(+), 36 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 72f54ca6..d1226ef7 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -121,7 +121,8 @@ $config = [ ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY => 'PT1M', /** - * Pushed Authorization Request (PAR) and Request Object URL (JAR) configurations. + * Pushed Authorization Request (PAR) and Request Object URL (JAR) + * configurations. */ ModuleConfig::OPTION_PAR_REQUEST_URI_TTL => 'PT10M', // PAR request URI expiration TTL (default: 10 minutes) ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS => false, // Require PAR globally (default: false) diff --git a/src/Controllers/PushedAuthorizationController.php b/src/Controllers/PushedAuthorizationController.php index fe2830f5..9c579d79 100644 --- a/src/Controllers/PushedAuthorizationController.php +++ b/src/Controllers/PushedAuthorizationController.php @@ -2,15 +2,6 @@ declare(strict_types=1); -/* - * This file is part of the simplesamlphp-module-oidc. - * - * Copyright (C) 2026 by the Spanish Research and Academic Network. - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - namespace SimpleSAML\Module\oidc\Controllers; use League\OAuth2\Server\Exception\OAuthServerException; @@ -152,8 +143,8 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface } /** - * Resolve the authorization request parameters which are to be persisted for later use at the - * authorization endpoint. + * Resolve the authorization request parameters which are to be persisted + * for later use at the authorization endpoint. * * @param mixed[] $bodyParams * @return mixed[] diff --git a/src/Server/RequestRules/Rules/AbstractRule.php b/src/Server/RequestRules/Rules/AbstractRule.php index b7ac999b..29dd7748 100644 --- a/src/Server/RequestRules/Rules/AbstractRule.php +++ b/src/Server/RequestRules/Rules/AbstractRule.php @@ -29,11 +29,13 @@ public function getKey(): string } /** - * Check if the authorization request is an OpenID Connect request (designated by the openid scope), as - * opposed to a plain OAuth 2.0 request. Scope is resolved from all request params, including the ones + * Check if the authorization request is an OpenID Connect request + * (designated by the openid scope), as opposed to a plain OAuth 2.0 + * request. Scope is resolved from all request params, including the ones * from Request Object / Request URI, if present. * * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods + * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ protected function isOidcAuthorizationRequest( ServerRequestInterface $request, diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index 526105bf..a14581b6 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -60,17 +60,20 @@ public function checkRule( return null; } - // Request param exists. Check if the result bag already has request object resolved. This can happen if the - // request object was used as a way to do automatic client registration in OpenID Federation. + // Request param exists. Check if the result bag already has a request + // object resolved. This can happen if the request object was used as + // a way to do automatic client registration in OpenID Federation. // @see ClientIdRule if ($currentResultBag->has($this->getKey())) { $loggerService->debug('Request object has already been resolved, skipping rule ' . $this->getKey()); return null; } - // There is no request object already resolved. We will do it now. Parse it using all available Request - // Object flavors, so we can differentiate between OpenID Connect Core Request Objects (which can be - // unsigned) and JAR Request Objects (which must be signed). + // There is no request object already resolved. We will do it now. + // Parse it using all available Request Object flavors, so we can + // differentiate between OpenID Connect Core Request Objects + // (which can be unsigned) and JAR Request Objects (which must be + // signed). $requestObjectBag = $this->requestParamsResolver->parseRequestObjectBag($requestParam); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ @@ -81,8 +84,9 @@ public function checkRule( $stateValue = ($currentResultBag->get(StateRule::class))?->getValue(); if (!$this->isOidcAuthorizationRequest($request, $allowedServerRequestMethods)) { - // This is a plain OAuth 2.0 authorization request, so JAR (RFC 9101) rules apply: the Request - // Object must be a signed JWT containing the Client ID claim. + // This is a plain OAuth 2.0 authorization request, so JAR + // (RFC 9101) rules apply: the Request Object must be a signed JWT + // containing the Client ID claim. $jarRequestObject = $requestObjectBag->get(JarRequestObject::class); if (!$jarRequestObject instanceof JarRequestObject) { throw OidcServerException::invalidRequest( @@ -111,8 +115,9 @@ public function checkRule( return new Result($this->getKey(), $jarRequestObject->getPayload()); } - // This is an OpenID Connect authorization request, so OpenID Connect Core rules apply: the Request - // Object can be unsigned (unless signature is required by policy). + // This is an OpenID Connect authorization request, so OpenID Connect + // Core rules apply: the Request Object can be unsigned + // (unless policy requires signature). $requestObject = $requestObjectBag->get(ConnectRequestObject::class); if (!$requestObject instanceof ConnectRequestObject) { throw OidcServerException::invalidRequest( diff --git a/src/Server/RequestRules/Rules/RequestUriRule.php b/src/Server/RequestRules/Rules/RequestUriRule.php index 202a1db9..5d54842b 100644 --- a/src/Server/RequestRules/Rules/RequestUriRule.php +++ b/src/Server/RequestRules/Rules/RequestUriRule.php @@ -76,8 +76,9 @@ public function checkRule( ): ?ResultInterface { $loggerService->debug('RequestUriRule::checkRule'); - // Note: we are intentionally working with raw request params here (not the merged view which includes - // params resolved from the request_uri itself). + // Note: we are intentionally working with raw request params here + // (not the merged view which includes params resolved from the + // request_uri itself). $requestUri = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( ParamsEnum::RequestUri->value, $request, @@ -243,8 +244,9 @@ protected function checkHttpsRequestUri( ); } - // Make sure the request_uri resolution ran (it is memoized in RequestParamsResolver, so this is - // cheap if other rules already triggered it), then grab the resolved Request Object Bag. + // Make sure the request_uri resolution ran (it is memoized in + // RequestParamsResolver, so this is inexpensive if other rules already + // triggered it), then grab the resolved Request Object Bag. $this->requestParamsResolver->getAllBasedOnAllowedMethods($request, $allowedServerRequestMethods); $requestObjectBag = $this->requestParamsResolver->getResolvedRequestUriBag($requestUri); @@ -256,8 +258,9 @@ protected function checkHttpsRequestUri( } if (!$this->isOidcAuthorizationRequest($request, $allowedServerRequestMethods)) { - // This is a plain OAuth 2.0 authorization request, so JAR (RFC 9101) rules apply: the Request - // Object must be a signed JWT containing the Client ID claim. + // This is a plain OAuth 2.0 authorization request, so JAR + // (RFC 9101) rules apply: the Request Object must be a signed + // JWT containing the Client ID claim. $requestObject = $requestObjectBag->get(JarRequestObject::class); if (!$requestObject instanceof JarRequestObject) { throw OidcServerException::invalidRequest( diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index c874322b..39718987 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -228,8 +228,8 @@ protected function resolveRequestUriParams(array $requestParams): array return []; } - // Using both request and request_uri params is not allowed. Don't resolve anything and let the - // RequestUriRule produce the proper error. + // Using both request and request_uri params is not allowed. Don't + // resolve anything and let the RequestUriRule produce the proper error. if (array_key_exists(ParamsEnum::Request->value, $requestParams)) { return []; } @@ -337,11 +337,13 @@ public function parseRequestObjectToken(string $token): Core\RequestObject } /** - * Parse the Request Object token using all available Request Object flavors (OpenID Connect Core, JAR, - * OpenID Federation). The returned bag contains an entry for every flavor for which the token parsed and - * passed flavor-specific validation, so it can be used to differentiate between, for example, OpenID - * Connect Core Request Objects (which can be unsigned) and JAR Request Objects (which must be signed). - * Note that this won't do signature validation. + * Parse the Request Object token using all available Request Object flavors + * (OpenID Connect Core, JAR, OpenID Federation). The returned bag contains + * an entry for every flavor for which the token parsed and passed + * flavor-specific validation, so it can be used to differentiate between, + * for example, OpenID Connect Core Request Objects (which can be unsigned) + * and JAR Request Objects (which must be signed). Note that this won't + * do signature validation. */ public function parseRequestObjectBag(string $token): RequestObjectBag { From a80eeccbdd0b3e5df4b51a28e1ca62ddd505c63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Jun 2026 10:28:29 +0200 Subject: [PATCH 05/11] WIP --- config/module_oidc.php.dist | 4 + src/Factories/RequestRulesManagerFactory.php | 1 - src/ModuleConfig.php | 11 + src/Server/RequestRules/Rules/ClientRule.php | 29 ++- .../RequestRules/Rules/RequestObjectRule.php | 71 ++++-- .../RequestRules/Rules/RequestUriRule.php | 124 ++-------- src/Services/Container.php | 1 - src/Services/OpMetadataService.php | 3 +- src/Utils/RequestParamsResolver.php | 220 ++++++++++++------ .../Rules/RequestObjectRuleTest.php | 25 +- .../RequestRules/Rules/RequestUriRuleTest.php | 131 ++--------- .../src/Services/OpMetadataServiceTest.php | 2 + .../src/Utils/RequestParamsResolverTest.php | 181 ++++++++------ 13 files changed, 403 insertions(+), 400 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index d1226ef7..5f642816 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -127,6 +127,10 @@ $config = [ ModuleConfig::OPTION_PAR_REQUEST_URI_TTL => 'PT10M', // PAR request URI expiration TTL (default: 10 minutes) ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS => false, // Require PAR globally (default: false) ModuleConfig::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT => false, // Reject unsigned request objects globally (default: false) + // Whether to support passing the Request Object by reference using the https request_uri parameter (JAR / + // OpenID Federation by reference). Set to false to mitigate DoS / SSRF by disabling outbound fetches. Note + // that this does not affect Pushed Authorization Request URIs (urn form), which are always supported. + ModuleConfig::OPTION_REQUEST_URI_PARAMETER_SUPPORTED => true, // Support https request_uri (default: true) ModuleConfig::OPTION_REQUEST_URI_TIMEOUT => 5, // Timeout for fetching request_uri (default: 5 seconds) ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES => 102400, // Maximum allowed response size for request_uri in bytes (default: 100KB) diff --git a/src/Factories/RequestRulesManagerFactory.php b/src/Factories/RequestRulesManagerFactory.php index ee8c78e4..699488fc 100644 --- a/src/Factories/RequestRulesManagerFactory.php +++ b/src/Factories/RequestRulesManagerFactory.php @@ -126,7 +126,6 @@ private function getDefaultRules(): array $this->requestParamsResolver, $this->helpers, $this->pushedAuthorizationRequestRepository, - $this->jwksResolver, $this->moduleConfig, ), new ResponseModeRule( diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 11cfd3f4..5c85f2eb 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -126,6 +126,7 @@ class ModuleConfig final public const string OPTION_PAR_REQUEST_URI_TTL = 'parRequestUriDuration'; final public const string OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS = 'requirePushedAuthorizationRequests'; final public const string OPTION_REQUIRE_SIGNED_REQUEST_OBJECT = 'requireSignedRequestObject'; + final public const string OPTION_REQUEST_URI_PARAMETER_SUPPORTED = 'requestUriParameterSupported'; final public const string OPTION_REQUEST_URI_TIMEOUT = 'requestUriTimeout'; final public const string OPTION_REQUEST_URI_MAX_SIZE_BYTES = 'requestUriMaxSizeBytes'; @@ -346,6 +347,16 @@ public function getRequireSignedRequestObject(): bool return $this->config()->getOptionalBoolean(self::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT, false); } + /** + * Whether the OP supports passing the Request Object by reference using the https request_uri parameter + * (JWT-Secured Authorization Request by reference / OpenID Federation Authentication Request by reference). + * Note that this does not affect Pushed Authorization Request URIs (urn form), which are always supported. + */ + public function getRequestUriParameterSupported(): bool + { + return $this->config()->getOptionalBoolean(self::OPTION_REQUEST_URI_PARAMETER_SUPPORTED, true); + } + public function getRequestUriTimeout(): int { return $this->config()->getOptionalInteger(self::OPTION_REQUEST_URI_TIMEOUT, 5); diff --git a/src/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php index 242c60d0..2ea4635d 100644 --- a/src/Server/RequestRules/Rules/ClientRule.php +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -29,6 +29,7 @@ use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Exceptions\JwsException; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Federation\RequestObject as FederationRequestObject; use Throwable; /** @@ -161,30 +162,26 @@ public function resolveFromFederation( ): ?ClientEntityInterface { $this->loggerService->debug('ClientRule: Resolving client from federation.'); // Federation is enabled. - // Check if we have a request object available. If not, we don't have anything else to do. - $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::Request->value, - $request, - $allowedMethods, - ); + // Check if we have a Request Object available, either passed by value (request param) or by reference + // (https request_uri param). The RequestParamsResolver does the heavy lifting (parsing / fetching). + // If not available, we don't have anything else to do. + $requestObjectBag = $this->requestParamsResolver->getRequestObjectBag($request, $allowedMethods); - if (is_null($requestParam)) { - $this->loggerService->error('ClientRule: No request param available, nothing to do.'); + if ($requestObjectBag === null) { + $this->loggerService->error('ClientRule: No request object available, nothing to do.'); return null; } - $this->loggerService->debug('ClientRule: Request param available.', ['requestParam' => $requestParam]); + // We must verify that the Request Object is the one compatible with OpenID Federation specification + // (not only Core specification). + $requestObject = $requestObjectBag->get(FederationRequestObject::class); - // We have a request object available. We must verify that it is the one compatible with OpenID Federation - // specification (not only Core specification). - try { - $requestObject = $this->requestParamsResolver->parseFederationRequestObjectToken($requestParam); - } catch (Throwable $exception) { - $this->loggerService->error('ClientRule: Request object error: ' . $exception->getMessage()); + if (!$requestObject instanceof FederationRequestObject) { + $this->loggerService->error('ClientRule: Request object is not a Federation Request Object.'); return null; } - $this->loggerService->debug('ClientRule: Request object parsed successfully.'); + $this->loggerService->debug('ClientRule: Federation Request object available.'); // We have a Federation-compatible Request Object. // The Audience (aud) value MUST be or include the OP's Issuer Identifier URL. diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index a14581b6..6acfefb5 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -50,31 +50,25 @@ public function checkRule( ): ?ResultInterface { $loggerService->debug('RequestObjectRule::checkRule'); - $requestParam = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( - ParamsEnum::Request->value, - $request, - $allowedServerRequestMethods, - ); - - if (is_null($requestParam)) { + // A Request Object can be passed by value (request param) or by reference (https request_uri param). + // Either way, the parsing/fetching is done by the RequestParamsResolver; here we only need to know + // whether such a Request Object is present for this request. + if (!$this->hasRequestObjectSource($request, $allowedServerRequestMethods)) { return null; } - // Request param exists. Check if the result bag already has a request - // object resolved. This can happen if the request object was used as - // a way to do automatic client registration in OpenID Federation. - // @see ClientIdRule + // Request object is present. Check if the result bag already has a request object resolved. This can + // happen if the request object was used as a way to do automatic client registration in OpenID + // Federation. + // @see ClientRule::resolveFromFederation() if ($currentResultBag->has($this->getKey())) { $loggerService->debug('Request object has already been resolved, skipping rule ' . $this->getKey()); return null; } - // There is no request object already resolved. We will do it now. - // Parse it using all available Request Object flavors, so we can - // differentiate between OpenID Connect Core Request Objects - // (which can be unsigned) and JAR Request Objects (which must be - // signed). - $requestObjectBag = $this->requestParamsResolver->parseRequestObjectBag($requestParam); + // Parse it using all available Request Object flavors, so we can differentiate between OpenID Connect + // Core Request Objects (which can be unsigned) and JAR Request Objects (which must be signed). + $requestObjectBag = $this->requestParamsResolver->getRequestObjectBag($request, $allowedServerRequestMethods); /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); @@ -83,6 +77,19 @@ public function checkRule( /** @var ?string $stateValue */ $stateValue = ($currentResultBag->get(StateRule::class))?->getValue(); + // The Request Object source is present, but it could not be parsed (by value) or fetched/parsed (by + // reference). Note that for the by-reference case, RequestUriRule would normally reject this earlier. + if ($requestObjectBag === null) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object could not be parsed or fetched.', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + if (!$this->isOidcAuthorizationRequest($request, $allowedServerRequestMethods)) { // This is a plain OAuth 2.0 authorization request, so JAR // (RFC 9101) rules apply: the Request Object must be a signed JWT @@ -153,6 +160,36 @@ public function checkRule( return new Result($this->getKey(), $requestObject->getPayload()); } + /** + * Check whether the request carries a Request Object, either by value (request param) or by reference + * (https request_uri param). Note that a Pushed Authorization Request URI (urn form) is not a Request + * Object source (it carries previously pushed params, handled by RequestUriRule). + * + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedServerRequestMethods + */ + protected function hasRequestObjectSource( + ServerRequestInterface $request, + array $allowedServerRequestMethods, + ): bool { + if ( + !is_null($this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::Request->value, + $request, + $allowedServerRequestMethods, + )) + ) { + return true; + } + + $requestUri = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( + ParamsEnum::RequestUri->value, + $request, + $allowedServerRequestMethods, + ); + + return is_string($requestUri) && str_starts_with(strtolower($requestUri), 'https://'); + } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ diff --git a/src/Server/RequestRules/Rules/RequestUriRule.php b/src/Server/RequestRules/Rules/RequestUriRule.php index 5d54842b..82911afc 100644 --- a/src/Server/RequestRules/Rules/RequestUriRule.php +++ b/src/Server/RequestRules/Rules/RequestUriRule.php @@ -26,26 +26,24 @@ use SimpleSAML\Module\oidc\Server\ResponseModes\QueryResponseMode; use SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; -use SimpleSAML\OpenID\Core\RequestObject as ConnectRequestObject; -use SimpleSAML\OpenID\Jar\RequestObject as JarRequestObject; /** - * Handle the request_uri authorization request parameter: - * - Pushed Authorization Request URIs (RFC 9126, urn form): validate existence, expiration, one-time use - * (consume on validation) and client binding, - * - https Request URIs (RFC 9101 / OpenID Connect Core, Request Object by reference): validate that the - * Request URI is registered for the client, and validate the fetched Request Object (signature, client - * binding), differentiating between OpenID Connect and plain OAuth 2.0 (JAR) requests, - * - enforce Pushed Authorization Request usage if required by server or client policy. - * - * Note that the actual resolution of params from the request_uri value is done in RequestParamsResolver, so - * that resolved params are transparently available to all other rules. + * Gatekeeper for the request_uri authorization request parameter. It does not parse, fetch or verify the + * Request Object itself (that is the job of the RequestParamsResolver and the RequestObjectRule); it only + * enforces request_uri usage policy: + * - request and request_uri must not be used together (RFC 9101), + * - client_id is required when using request_uri, + * - Pushed Authorization Request URIs (RFC 9126, urn form): existence, expiration, one-time use (consume on + * validation) and client binding, + * - https Request URIs (Request Object by reference): the OP must support the request_uri parameter, and the + * Request Object must be resolvable (registration / federation policy is enforced in RequestParamsResolver), + * - Pushed Authorization Request usage if required by server or client policy. * * @see \SimpleSAML\Module\oidc\Utils\RequestParamsResolver + * @see \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule */ class RequestUriRule extends AbstractRule { @@ -53,7 +51,6 @@ public function __construct( RequestParamsResolver $requestParamsResolver, Helpers $helpers, protected PushedAuthorizationRequestRepository $pushedAuthorizationRequestRepository, - protected JwksResolver $jwksResolver, protected ModuleConfig $moduleConfig, ) { parent::__construct($requestParamsResolver, $helpers); @@ -76,9 +73,8 @@ public function checkRule( ): ?ResultInterface { $loggerService->debug('RequestUriRule::checkRule'); - // Note: we are intentionally working with raw request params here - // (not the merged view which includes params resolved from the - // request_uri itself). + // Note: we are intentionally working with raw request params here (not the merged view which includes + // params resolved from the request_uri itself). $requestUri = $this->requestParamsResolver->getFromRequestBasedOnAllowedMethods( ParamsEnum::RequestUri->value, $request, @@ -140,9 +136,7 @@ public function checkRule( if (str_starts_with(strtolower($requestUri), 'https://')) { return $this->checkHttpsRequestUri( $requestUri, - $client, $request, - $currentResultBag, $isParRequired, $allowedServerRequestMethods, ); @@ -224,9 +218,7 @@ protected function checkPushedAuthorizationRequestUri( */ protected function checkHttpsRequestUri( string $requestUri, - ClientEntityInterface $client, ServerRequestInterface $request, - ResultBagInterface $currentResultBag, bool $isParRequired, array $allowedServerRequestMethods, ): ResultInterface { @@ -237,98 +229,26 @@ protected function checkHttpsRequestUri( ); } - if (!in_array($requestUri, $client->getRequestUris(), true)) { + if (!$this->moduleConfig->getRequestUriParameterSupported()) { throw OidcServerException::invalidRequest( ParamsEnum::RequestUri->value, - 'The request_uri is not registered for this client.', + 'Passing the request object by reference (request_uri) is not supported.', ); } - // Make sure the request_uri resolution ran (it is memoized in - // RequestParamsResolver, so this is inexpensive if other rules already - // triggered it), then grab the resolved Request Object Bag. - $this->requestParamsResolver->getAllBasedOnAllowedMethods($request, $allowedServerRequestMethods); - - $requestObjectBag = $this->requestParamsResolver->getResolvedRequestUriBag($requestUri); + // Make sure the Request Object behind the request_uri can actually be resolved (fetched and parsed, + // and allowed by registration / federation policy in RequestParamsResolver). The signature and other + // request object validations are then done by the RequestObjectRule (or by ClientRule for the + // federation case). + $requestObjectBag = $this->requestParamsResolver->getRequestObjectBag($request, $allowedServerRequestMethods); if ($requestObjectBag === null) { throw OidcServerException::invalidRequest( ParamsEnum::RequestUri->value, - 'Could not fetch or parse the Request Object from request_uri.', - ); - } - - if (!$this->isOidcAuthorizationRequest($request, $allowedServerRequestMethods)) { - // This is a plain OAuth 2.0 authorization request, so JAR - // (RFC 9101) rules apply: the Request Object must be a signed - // JWT containing the Client ID claim. - $requestObject = $requestObjectBag->get(JarRequestObject::class); - if (!$requestObject instanceof JarRequestObject) { - throw OidcServerException::invalidRequest( - ParamsEnum::RequestUri->value, - 'Request object is not a valid JAR Request Object (note that it must be signed).', - ); - } - - $this->verifySignature($requestObject, $client); - } else { - // This is an OpenID Connect authorization request, so OpenID Connect Core rules apply: the - // Request Object can be unsigned (unless signature is required by policy). - $requestObject = $requestObjectBag->get(ConnectRequestObject::class); - if (!$requestObject instanceof ConnectRequestObject) { - throw OidcServerException::invalidRequest( - ParamsEnum::RequestUri->value, - 'Request object is not a valid Request Object.', - ); - } - - if ($requestObject->isProtected()) { - $this->verifySignature($requestObject, $client); - } elseif ( - $this->moduleConfig->getRequireSignedRequestObject() || - $client->getRequireSignedRequestObject() - ) { - throw OidcServerException::invalidRequest( - ParamsEnum::RequestUri->value, - 'Request object must be signed (alg: none is not allowed).', - ); - } - } - - $payload = $requestObject->getPayload(); - - /** @psalm-suppress MixedAssignment */ - $clientIdClaim = $payload[ParamsEnum::ClientId->value] ?? null; - if ($clientIdClaim !== $client->getIdentifier()) { - throw OidcServerException::invalidRequest( - ParamsEnum::RequestUri->value, - 'Client ID claim in request object does not match the client_id parameter.', + 'The request_uri could not be resolved (it may not be allowed for this client, or the fetch ' . + 'failed).', ); } - // Mark the Request Object as resolved (and validated), so that RequestObjectRule does not need to - // run again for it. - $currentResultBag->add(new Result(RequestObjectRule::class, $payload)); - return new Result($this->getKey(), $requestUri); } - - /** - * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException - */ - protected function verifySignature( - ConnectRequestObject|JarRequestObject $requestObject, - ClientEntityInterface $client, - ): void { - ($jwks = $this->jwksResolver->forClient($client)) || throw OidcServerException::accessDenied( - 'can not validate request object, client JWKS not available', - ); - - try { - $requestObject->verifyWithKeySet($jwks); - } catch (\Throwable $exception) { - throw OidcServerException::accessDenied( - 'request object validation failed: ' . $exception->getMessage(), - ); - } - } } diff --git a/src/Services/Container.php b/src/Services/Container.php index 5dc6cccb..ba43b7ef 100644 --- a/src/Services/Container.php +++ b/src/Services/Container.php @@ -485,7 +485,6 @@ public function __construct() $requestParamsResolver, $helpers, $pushedAuthorizationRequestRepository, - $jwksResolver, $moduleConfig, ), new ResponseModeRule( diff --git a/src/Services/OpMetadataService.php b/src/Services/OpMetadataService.php index 99bae8d8..1deb84df 100644 --- a/src/Services/OpMetadataService.php +++ b/src/Services/OpMetadataService.php @@ -77,7 +77,8 @@ private function initMetadata(): void 'none', ...$supportedSignatureAlgorithmNames, ]; - $this->metadata[ClaimsEnum::RequestUriParameterSupported->value] = true; + $this->metadata[ClaimsEnum::RequestUriParameterSupported->value] = + $this->moduleConfig->getRequestUriParameterSupported(); // The https request_uri values must be pre-registered for the client (request_uris client metadata). $this->metadata[ClaimsEnum::RequireRequestUriRegistration->value] = true; $this->metadata[ClaimsEnum::PushedAuthorizationRequestEndpoint->value] = diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index 39718987..1196ee0e 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -6,6 +6,7 @@ use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; use SimpleSAML\Module\oidc\Helpers; @@ -30,19 +31,26 @@ class RequestParamsResolver { /** - * Resolved request_uri params, keyed by request_uri value. + * Request Object Bags parsed from a Request Object JWT passed by value (request param), keyed by token. * - * @var array + * @var array */ - protected array $resolvedRequestUriParams = []; + protected array $requestObjectBagsByToken = []; /** - * Request Object Bags resolved from (fetched) https request_uri values, + * Request Object Bags fetched and parsed from an https Request URI passed by reference (request_uri param), * keyed by request_uri value. * * @var array */ - protected array $resolvedRequestUriBags = []; + protected array $requestObjectBagsByUri = []; + + /** + * Params resolved from Pushed Authorization Request URIs (urn form), keyed by request_uri value. + * + * @var array + */ + protected array $pushedAuthorizationRequestParams = []; public function __construct( protected readonly Helpers $helpers, @@ -193,29 +201,35 @@ public function getFromRequestBasedOnAllowedMethods( } /** - * Check if Request Object is present as a request param and parse it to - * use its claims as params. + * Check if Request Object is present as a request param (passed by value) and parse it to use its claims + * as params. * - * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @return mixed[] */ protected function resolveRequestObjectParams(array $requestParams): array { - if (array_key_exists(ParamsEnum::Request->value, $requestParams)) { - return $this->parseRequestObjectToken((string)$requestParams[ParamsEnum::Request->value])->getPayload(); + if ( + (!array_key_exists(ParamsEnum::Request->value, $requestParams)) || + (!is_string($token = $requestParams[ParamsEnum::Request->value])) || + ($token === '') + ) { + return []; } - return []; + // Use the OpenID Connect Core flavor for (unverified) param resolution, since it is the most lenient + // one (signature validation and policy checks are done in RequestObjectRule). + return $this->parseRequestObjectBagByToken($token)?->get(Core\RequestObject::class)?->getPayload() ?? []; } /** - * Check if Request URI is present as a request param and resolve its - * claims to use them as params. For Pushed Authorization Request URIs - * (urn form), params are resolved from the previously pushed (validated) - * authorization request. For https Request URIs, the Request Object is - * fetched and parsed, but note that this won't do signature validation - * of it, nor any policy checks like one-time use or expiration. + * Check if Request URI is present as a request param and resolve its claims to use them as params. For + * Pushed Authorization Request URIs (urn form), params are resolved from the previously pushed (validated) + * authorization request. For https Request URIs, the Request Object is fetched and parsed (if allowed by + * policy), but note that this won't do signature validation of it, nor any policy checks like one-time use + * or expiration. * * @see \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestUriRule + * @see \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule * @return mixed[] */ protected function resolveRequestUriParams(array $requestParams): array @@ -228,27 +242,20 @@ protected function resolveRequestUriParams(array $requestParams): array return []; } - // Using both request and request_uri params is not allowed. Don't - // resolve anything and let the RequestUriRule produce the proper error. + // Using both request and request_uri params is not allowed. Don't resolve anything and let the + // RequestUriRule produce the proper error. if (array_key_exists(ParamsEnum::Request->value, $requestParams)) { return []; } - if (array_key_exists($requestUri, $this->resolvedRequestUriParams)) { - return $this->resolvedRequestUriParams[$requestUri]; - } - + // Pushed Authorization Request URI (urn form): resolve from the previously pushed (validated) params. if (str_starts_with($requestUri, PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX)) { - return $this->resolvedRequestUriParams[$requestUri] = - $this->resolvePushedAuthorizationRequestParams($requestUri); + return $this->resolvePushedAuthorizationRequestParams($requestUri); } - if (str_starts_with(strtolower($requestUri), 'https://')) { - return $this->resolvedRequestUriParams[$requestUri] = - $this->resolveHttpsRequestUriParams($requestUri, $requestParams); - } - - return $this->resolvedRequestUriParams[$requestUri] = []; + // https Request URI (by reference): fetch and parse the Request Object (if allowed by policy). + return $this->fetchRequestObjectBagByUri($requestUri, $requestParams) + ?->get(Core\RequestObject::class)?->getPayload() ?? []; } /** @@ -256,70 +263,144 @@ protected function resolveRequestUriParams(array $requestParams): array */ protected function resolvePushedAuthorizationRequestParams(string $requestUri): array { + if (array_key_exists($requestUri, $this->pushedAuthorizationRequestParams)) { + return $this->pushedAuthorizationRequestParams[$requestUri]; + } + try { - return $this->pushedAuthorizationRequestRepository->findValid($requestUri)?->getParameters() ?? []; + return $this->pushedAuthorizationRequestParams[$requestUri] = + $this->pushedAuthorizationRequestRepository->findValid($requestUri)?->getParameters() ?? []; } catch (\Throwable $throwable) { $this->loggerService->warning( 'RequestParamsResolver: error resolving pushed authorization request: ' . $throwable->getMessage(), compact('requestUri'), ); - return []; + return $this->pushedAuthorizationRequestParams[$requestUri] = []; } } /** - * Fetch the Request Object from the https Request URI and use its claims as params. The Request URI must be - * registered for the client resolved from the client_id request param (it is fetched only in that case). + * Resolve the Request Object Bag for the current request, regardless of whether the Request Object was + * passed by value (request param) or by reference (https request_uri param). For Pushed Authorization + * Request URIs (urn form) this returns null, since PAR carries previously pushed params, not a Request + * Object. Note that this won't do signature validation; that is done in RequestObjectRule. * - * @return mixed[] + * @param \SimpleSAML\OpenID\Codebooks\HttpMethodsEnum[] $allowedMethods */ - protected function resolveHttpsRequestUriParams(string $requestUri, array $requestParams): array - { - $this->resolvedRequestUriBags[$requestUri] = null; + public function getRequestObjectBag( + Request|ServerRequestInterface $request, + array $allowedMethods = [HttpMethodsEnum::GET], + ): ?RequestObjectBag { + $requestParams = $this->getAllFromRequestBasedOnAllowedMethods($request, $allowedMethods); + /** @psalm-suppress MixedAssignment */ if ( - (!array_key_exists(ParamsEnum::ClientId->value, $requestParams)) || - (!is_string($clientId = $requestParams[ParamsEnum::ClientId->value])) || - ($clientId === '') + array_key_exists(ParamsEnum::Request->value, $requestParams) && + is_string($token = $requestParams[ParamsEnum::Request->value]) && + $token !== '' ) { - return []; + return $this->parseRequestObjectBagByToken($token); } - $client = $this->clientRepository->getClientEntity($clientId); + /** @psalm-suppress MixedAssignment */ if ( - (!$client instanceof ClientEntityInterface) || - (!in_array($requestUri, $client->getRequestUris(), true)) + array_key_exists(ParamsEnum::RequestUri->value, $requestParams) && + is_string($requestUri = $requestParams[ParamsEnum::RequestUri->value]) && + str_starts_with(strtolower($requestUri), 'https://') ) { - return []; + return $this->fetchRequestObjectBagByUri($requestUri, $requestParams); + } + + return null; + } + + /** + * Parse (memoized) the Request Object token using all available Request Object flavors (OpenID Connect + * Core, JAR, OpenID Federation). The returned bag contains an entry for every flavor for which the token + * parsed and passed flavor-specific validation, so it can be used to differentiate between, for example, + * OpenID Connect Core Request Objects (which can be unsigned) and JAR Request Objects (which must be + * signed). Note that this won't do signature validation. + */ + protected function parseRequestObjectBagByToken(string $token): ?RequestObjectBag + { + if (!array_key_exists($token, $this->requestObjectBagsByToken)) { + try { + $this->requestObjectBagsByToken[$token] = $this->requestObject->requestObjectParser() + ->fromToken($token); + } catch (\Throwable $throwable) { + $this->loggerService->warning( + 'RequestParamsResolver: error parsing request object: ' . $throwable->getMessage(), + ); + $this->requestObjectBagsByToken[$token] = null; + } + } + + return $this->requestObjectBagsByToken[$token]; + } + + /** + * Fetch and parse (memoized) the Request Object from the given https Request URI, if allowed by policy. + */ + protected function fetchRequestObjectBagByUri(string $requestUri, array $requestParams): ?RequestObjectBag + { + if (array_key_exists($requestUri, $this->requestObjectBagsByUri)) { + return $this->requestObjectBagsByUri[$requestUri]; + } + + if (!$this->isHttpsRequestUriFetchAllowed($requestUri, $requestParams)) { + return $this->requestObjectBagsByUri[$requestUri] = null; } try { - $requestObjectBag = $this->requestObject->requestObjectParser()->fromRequestUri( - $requestUri, - $this->moduleConfig->getRequestUriTimeout(), - $this->moduleConfig->getRequestUriMaxSizeBytes(), - ); + return $this->requestObjectBagsByUri[$requestUri] = $this->requestObject->requestObjectParser() + ->fromRequestUri( + $requestUri, + $this->moduleConfig->getRequestUriTimeout(), + $this->moduleConfig->getRequestUriMaxSizeBytes(), + ); } catch (\Throwable $throwable) { $this->loggerService->warning( 'RequestParamsResolver: error fetching request object from request_uri: ' . $throwable->getMessage(), compact('requestUri'), ); - return []; + return $this->requestObjectBagsByUri[$requestUri] = null; } - - $this->resolvedRequestUriBags[$requestUri] = $requestObjectBag; - - // Use the OpenID Connect Core flavor for (unverified) param resolution, since it is the most lenient - // one (signature validation and policy checks are handled in RequestUriRule). - return $requestObjectBag->get(Core\RequestObject::class)?->getPayload() ?? []; } /** - * Get the Request Object Bag resolved from the given (fetched) https request_uri value, if any. + * Decide whether an https Request URI (Request Object by reference) is allowed to be fetched. This is the + * single authorization point for outbound Request Object fetches (SSRF / DoS surface): + * - the OP must support the request_uri parameter (request_uri_parameter_supported), + * - for registered (non-federation) clients, the request_uri must be pre-registered in the client's + * request_uris (RFC 9126 exact-matching), + * - for clients not in storage or registered through OpenID Federation, fetching is allowed when + * federation is enabled (trust is validated after the fetch, in ClientRule). */ - public function getResolvedRequestUriBag(string $requestUri): ?RequestObjectBag + protected function isHttpsRequestUriFetchAllowed(string $requestUri, array $requestParams): bool { - return $this->resolvedRequestUriBags[$requestUri] ?? null; + if (!$this->moduleConfig->getRequestUriParameterSupported()) { + return false; + } + + if ( + (!array_key_exists(ParamsEnum::ClientId->value, $requestParams)) || + (!is_string($clientId = $requestParams[ParamsEnum::ClientId->value])) || + ($clientId === '') + ) { + return false; + } + + $client = $this->clientRepository->getClientEntity($clientId); + + if ( + $client instanceof ClientEntityInterface && + $client->getRegistrationType() !== RegistrationTypeEnum::FederatedAutomatic + ) { + return in_array($requestUri, $client->getRequestUris(), true); + } + + // Client not in storage, or registered through OpenID Federation: federation by-reference path. + return $this->moduleConfig->getFederationEnabled(); } /** @@ -329,27 +410,12 @@ public function getResolvedRequestUriBag(string $requestUri): ?RequestObjectBag * @param string $token * @return \SimpleSAML\OpenID\Core\RequestObject * @throws \SimpleSAML\OpenID\Exceptions\JwsException - * @see \SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule */ public function parseRequestObjectToken(string $token): Core\RequestObject { return $this->core->requestObjectFactory()->fromToken($token); } - /** - * Parse the Request Object token using all available Request Object flavors - * (OpenID Connect Core, JAR, OpenID Federation). The returned bag contains - * an entry for every flavor for which the token parsed and passed - * flavor-specific validation, so it can be used to differentiate between, - * for example, OpenID Connect Core Request Objects (which can be unsigned) - * and JAR Request Objects (which must be signed). Note that this won't - * do signature validation. - */ - public function parseRequestObjectBag(string $token): RequestObjectBag - { - return $this->requestObject->requestObjectParser()->fromToken($token); - } - /** * Parse the Request Object token according to OpenID Federation * specification. Note that this won't do signature validation of it. diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php index e1b0755a..5cb50f58 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php @@ -86,6 +86,7 @@ protected function sut( protected function prepareOidcRequest(): void { + // A `request` param signals a Request Object is present (by value). $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); // OpenID Connect request is designated by the openid scope. $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('openid'); @@ -93,8 +94,8 @@ protected function prepareOidcRequest(): void ->willReturnMap([ [RequestObject::class, $this->requestObjectMock], ]); - $this->requestParamsResolverMock->method('parseRequestObjectBag') - ->with('token')->willReturn($this->requestObjectBagMock); + $this->requestParamsResolverMock->method('getRequestObjectBag') + ->willReturn($this->requestObjectBagMock); } protected function prepareOAuth2Request(?JarRequestObject $jarRequestObject = null): void @@ -107,8 +108,8 @@ protected function prepareOAuth2Request(?JarRequestObject $jarRequestObject = nu [RequestObject::class, $this->requestObjectMock], [JarRequestObject::class, $jarRequestObject], ]); - $this->requestParamsResolverMock->method('parseRequestObjectBag') - ->with('token')->willReturn($this->requestObjectBagMock); + $this->requestParamsResolverMock->method('getRequestObjectBag') + ->willReturn($this->requestObjectBagMock); } public function testCanCreateInstance(): void @@ -128,6 +129,22 @@ public function testRequestParamCanBeAbsent(): void $this->assertNull($result); } + public function testThrowsWhenRequestObjectSourceIsPresentButBagCannotBeResolved(): void + { + // `request` param present (source present), but the resolver could not parse/fetch it (null bag). + $this->requestParamsResolverMock->method('getFromRequestBasedOnAllowedMethods')->willReturn('token'); + $this->requestParamsResolverMock->method('getRequestObjectBag')->willReturn(null); + + $this->expectException(OidcServerException::class); + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + public function testUnprotectedRequestParamCanBeUsedForOidcRequest(): void { $this->prepareOidcRequest(); diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php index aa5f0608..106664b8 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestUriRuleTest.php @@ -20,14 +20,10 @@ use SimpleSAML\Module\oidc\Server\RequestRules\Result; use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule; -use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestObjectRule; use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestUriRule; use SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface; use SimpleSAML\Module\oidc\Services\LoggerService; -use SimpleSAML\Module\oidc\Utils\JwksResolver; use SimpleSAML\Module\oidc\Utils\RequestParamsResolver; -use SimpleSAML\OpenID\Core\RequestObject; -use SimpleSAML\OpenID\Jar\RequestObject as JarRequestObject; use SimpleSAML\OpenID\RequestObject\RequestObjectBag; #[CoversClass(RequestUriRule::class)] @@ -41,7 +37,6 @@ class RequestUriRuleTest extends TestCase protected MockObject $resultBagMock; protected MockObject $requestParamsResolverMock; protected MockObject $pushedAuthorizationRequestRepositoryMock; - protected MockObject $jwksResolverMock; protected MockObject $moduleConfigMock; protected MockObject $parEntityMock; protected Stub $requestStub; @@ -61,8 +56,8 @@ protected function setUp(): void $this->pushedAuthorizationRequestRepositoryMock = $this->createMock( PushedAuthorizationRequestRepository::class, ); - $this->jwksResolverMock = $this->createMock(JwksResolver::class); $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(true); $this->parEntityMock = $this->createMock(PushedAuthorizationRequestEntity::class); $this->requestStub = $this->createStub(ServerRequestInterface::class); $this->loggerServiceMock = $this->createMock(LoggerService::class); @@ -76,7 +71,6 @@ protected function sut(): RequestUriRule $this->requestParamsResolverMock, $this->helpers, $this->pushedAuthorizationRequestRepositoryMock, - $this->jwksResolverMock, $this->moduleConfigMock, ); } @@ -121,7 +115,6 @@ public function testThrowsIfParIsRequiredGloballyButNotUsed(): void $this->moduleConfigMock->method('getRequirePushedAuthorizationRequests')->willReturn(true); $this->expectException(OidcServerException::class); - $this->expectExceptionMessageMatches('/invalid/i'); $this->checkRule(); } @@ -246,125 +239,51 @@ public function testThrowsForHttpsRequestUriIfParIsRequired(): void $this->checkRule(); } - public function testThrowsForUnregisteredHttpsRequestUri(): void + public function testThrowsForHttpsRequestUriIfNotSupported(): void { - $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); - $this->clientMock->method('getRequestUris')->willReturn(['https://client.example.org/other.jwt']); + // Override the default (true) set in setUp via a fresh module config mock. + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(false); - $this->expectException(OidcServerException::class); - $this->checkRule(); - } - - public function testThrowsForUnresolvableHttpsRequestUri(): void - { $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); - $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); - $this->requestParamsResolverMock->method('getResolvedRequestUriBag')->willReturn(null); - $this->expectException(OidcServerException::class); - $this->checkRule(); - } - - /** - * @param array $bagContent - */ - protected function prepareRequestObjectBag(array $bagContent): void - { - $requestObjectBagMock = $this->createMock(RequestObjectBag::class); - $requestObjectBagMock->method('get') - ->willReturnCallback(fn(string $class): ?object => $bagContent[$class] ?? null); - $this->requestParamsResolverMock->method('getResolvedRequestUriBag') - ->with(self::HTTPS_REQUEST_URI) - ->willReturn($requestObjectBagMock); - } - - public function testThrowsForOAuth2RequestIfFetchedRequestObjectIsNotJar(): void - { - $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); - $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); - // No openid scope, so this is a plain OAuth 2.0 request (JAR rules apply). For example, an - // unsigned Request Object is not a valid JAR Request Object. - $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('profile'); - $connectRequestObjectMock = $this->createMock(RequestObject::class); - $this->prepareRequestObjectBag([ - RequestObject::class => $connectRequestObjectMock, - JarRequestObject::class => null, - ]); + $sut = new RequestUriRule( + $this->requestParamsResolverMock, + $this->helpers, + $this->pushedAuthorizationRequestRepositoryMock, + $moduleConfigMock, + ); $this->expectException(OidcServerException::class); - $this->checkRule(); - } - - public function testCanUseFetchedJarRequestObjectForOAuth2Request(): void - { - $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); - $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); - $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('profile'); - - $jarRequestObjectMock = $this->createMock(JarRequestObject::class); - $jarRequestObjectMock->method('getPayload')->willReturn(['client_id' => 'client123']); - $jarRequestObjectMock->expects($this->once())->method('verifyWithKeySet')->with(['jwks']); - $this->prepareRequestObjectBag([JarRequestObject::class => $jarRequestObjectMock]); - - $this->jwksResolverMock->method('forClient')->with($this->clientMock)->willReturn(['jwks']); - - // The validated Request Object payload is marked as resolved for other rules. - $this->resultBagMock->expects($this->once())->method('add') - ->with($this->callback( - fn(Result $result): bool => $result->getKey() === RequestObjectRule::class, - )); - - $result = $this->checkRule(); - - $this->assertInstanceOf(Result::class, $result); - $this->assertSame(self::HTTPS_REQUEST_URI, $result->getValue()); + $sut->checkRule( + $this->requestStub, + $this->resultBagMock, + $this->loggerServiceMock, + [], + $this->responseModeStub, + ); } - public function testThrowsForOidcRequestIfUnsignedRequestObjectIsFetchedButSignatureIsRequired(): void + public function testThrowsForUnresolvableHttpsRequestUri(): void { $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); - $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); - // OpenID Connect request is designated by the openid scope. - $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('openid'); - $this->moduleConfigMock->method('getRequireSignedRequestObject')->willReturn(true); - - $connectRequestObjectMock = $this->createMock(RequestObject::class); - $connectRequestObjectMock->method('isProtected')->willReturn(false); - $this->prepareRequestObjectBag([RequestObject::class => $connectRequestObjectMock]); + // Resolver could not fetch/parse (or policy denied the fetch) -> null bag. + $this->requestParamsResolverMock->method('getRequestObjectBag')->willReturn(null); $this->expectException(OidcServerException::class); $this->checkRule(); } - public function testCanUseFetchedUnsignedRequestObjectForOidcRequest(): void + public function testCanUseResolvableHttpsRequestUri(): void { $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); - $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); - $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('openid'); - - $connectRequestObjectMock = $this->createMock(RequestObject::class); - $connectRequestObjectMock->method('isProtected')->willReturn(false); - $connectRequestObjectMock->method('getPayload')->willReturn(['client_id' => 'client123']); - $this->prepareRequestObjectBag([RequestObject::class => $connectRequestObjectMock]); + // Resolver produced a bag; signature/flavor validation is RequestObjectRule's job, not this rule's. + $this->requestParamsResolverMock->method('getRequestObjectBag') + ->willReturn($this->createMock(RequestObjectBag::class)); $result = $this->checkRule(); $this->assertInstanceOf(Result::class, $result); $this->assertSame(self::HTTPS_REQUEST_URI, $result->getValue()); } - - public function testThrowsIfFetchedRequestObjectClientIdClaimDoesNotMatch(): void - { - $this->prepareRawParams(['request_uri' => self::HTTPS_REQUEST_URI, 'client_id' => 'client123']); - $this->clientMock->method('getRequestUris')->willReturn([self::HTTPS_REQUEST_URI]); - $this->requestParamsResolverMock->method('getAsStringBasedOnAllowedMethods')->willReturn('openid'); - - $connectRequestObjectMock = $this->createMock(RequestObject::class); - $connectRequestObjectMock->method('isProtected')->willReturn(false); - $connectRequestObjectMock->method('getPayload')->willReturn(['client_id' => 'otherClient']); - $this->prepareRequestObjectBag([RequestObject::class => $connectRequestObjectMock]); - - $this->expectException(OidcServerException::class); - $this->checkRule(); - } } diff --git a/tests/unit/src/Services/OpMetadataServiceTest.php b/tests/unit/src/Services/OpMetadataServiceTest.php index ea889863..bcdea1e9 100644 --- a/tests/unit/src/Services/OpMetadataServiceTest.php +++ b/tests/unit/src/Services/OpMetadataServiceTest.php @@ -81,6 +81,8 @@ public function setUp(): void $this->moduleConfigMock->method('getProtocolSignatureKeyPairBag') ->willReturn($this->signatureKeyPairBagMock); + + $this->moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(true); } /** diff --git a/tests/unit/src/Utils/RequestParamsResolverTest.php b/tests/unit/src/Utils/RequestParamsResolverTest.php index 4fefa17e..fbe6a94c 100644 --- a/tests/unit/src/Utils/RequestParamsResolverTest.php +++ b/tests/unit/src/Utils/RequestParamsResolverTest.php @@ -5,11 +5,11 @@ namespace SimpleSAML\Test\Module\oidc\unit\Utils; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use SimpleSAML\Module\oidc\Bridges\PsrHttpBridge; +use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Entities\PushedAuthorizationRequestEntity; use SimpleSAML\Module\oidc\Factories\Entities\PushedAuthorizationRequestEntityFactory; @@ -29,7 +29,6 @@ use SimpleSAML\OpenID\RequestObject\RequestObjectParser; #[CoversClass(RequestParamsResolver::class)] -#[UsesClass(RequestObjectBag::class)] class RequestParamsResolverTest extends TestCase { protected MockObject $helpersMock; @@ -80,6 +79,9 @@ protected function setUp(): void $this->requestObjectFacadeMock->method('requestObjectParser') ->willReturn($this->requestObjectParserMock); $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(true); + $this->moduleConfigMock->method('getRequestUriTimeout')->willReturn(5); + $this->moduleConfigMock->method('getRequestUriMaxSizeBytes')->willReturn(102400); $this->clientRepositoryMock = $this->createMock(ClientRepository::class); $this->pushedAuthorizationRequestRepositoryMock = $this->createMock( PushedAuthorizationRequestRepository::class, @@ -111,6 +113,25 @@ protected function mock( ); } + protected function bagWithCore(): MockObject + { + $bag = $this->createMock(RequestObjectBag::class); + $bag->method('get')->willReturnMap([[RequestObject::class, $this->requestObjectMock]]); + + return $bag; + } + + protected function helpersWithParams(array $params): MockObject + { + $httpHelperMock = $this->createMock(Helpers\Http::class); + $httpHelperMock->method('getAllRequestParams')->willReturn($params); + $httpHelperMock->method('getAllRequestParamsBasedOnAllowedMethods')->willReturn($params); + $helpersMock = $this->createMock(Helpers::class); + $helpersMock->method('http')->willReturn($httpHelperMock); + + return $helpersMock; + } + public function testCanCreateInstance(): void { $this->assertInstanceOf(RequestParamsResolver::class, $this->mock()); @@ -146,12 +167,9 @@ public function testCanGetAllWithNoRequestObject(): void public function testCanGetAllWithRequestObject(): void { $queryParams = [...$this->queryParams, 'request' => 'token']; + $helpersMock = $this->helpersWithParams($queryParams); - $httpHelperMock = $this->createMock(Helpers\Http::class); - $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); - $helpersMock = $this->createMock(Helpers::class); - $helpersMock->method('http')->willReturn($httpHelperMock); - $this->requestObjectMock->expects($this->once())->method('getPayload'); + $this->requestObjectParserMock->method('fromToken')->with('token')->willReturn($this->bagWithCore()); $this->assertSame( array_merge($queryParams, $this->requestObjectParams), @@ -203,15 +221,12 @@ public function testCanGetAllWithPushedAuthorizationRequestUri(): void { $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; $queryParams = [...$this->queryParams, 'request_uri' => $requestUri]; - - $httpHelperMock = $this->createMock(Helpers\Http::class); - $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); - $helpersMock = $this->createMock(Helpers::class); - $helpersMock->method('http')->willReturn($httpHelperMock); + $helpersMock = $this->helpersWithParams($queryParams); $parEntityMock = $this->createMock(PushedAuthorizationRequestEntity::class); $parEntityMock->method('getParameters')->willReturn($this->requestObjectParams); + // Resolution is memoized, so the repository is queried only once across repeated getAll() calls. $this->pushedAuthorizationRequestRepositoryMock->expects($this->once()) ->method('findValid') ->with($requestUri) @@ -223,8 +238,6 @@ public function testCanGetAllWithPushedAuthorizationRequestUri(): void array_merge($queryParams, $this->requestObjectParams), $sut->getAll($this->requestMock), ); - - // Resolution is memoized, so the repository is not queried again. $this->assertSame( array_merge($queryParams, $this->requestObjectParams), $sut->getAll($this->requestMock), @@ -235,11 +248,7 @@ public function testGetAllResolvesNothingForInvalidPushedAuthorizationRequestUri { $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; $queryParams = [...$this->queryParams, 'request_uri' => $requestUri]; - - $httpHelperMock = $this->createMock(Helpers\Http::class); - $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); - $helpersMock = $this->createMock(Helpers::class); - $helpersMock->method('http')->willReturn($httpHelperMock); + $helpersMock = $this->helpersWithParams($queryParams); $this->pushedAuthorizationRequestRepositoryMock->method('findValid')->willReturn(null); @@ -253,43 +262,30 @@ public function testGetAllSkipsRequestUriResolutionIfRequestParamIsAlsoPresent() { $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'request' => 'token']; + $helpersMock = $this->helpersWithParams($queryParams); - $httpHelperMock = $this->createMock(Helpers\Http::class); - $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); - $helpersMock = $this->createMock(Helpers::class); - $helpersMock->method('http')->willReturn($httpHelperMock); - + $this->requestObjectParserMock->method('fromToken')->willReturn($this->bagWithCore()); $this->pushedAuthorizationRequestRepositoryMock->expects($this->never())->method('findValid'); $this->mock($helpersMock)->getAll($this->requestMock); } - public function testCanGetAllWithHttpsRequestUriForRegisteredUri(): void + public function testCanGetAllWithHttpsRequestUriForRegisteredClient(): void { $requestUri = 'https://client.example.org/request-object.jwt'; $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'client123']; - - $httpHelperMock = $this->createMock(Helpers\Http::class); - $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); - $helpersMock = $this->createMock(Helpers::class); - $helpersMock->method('http')->willReturn($httpHelperMock); + $helpersMock = $this->helpersWithParams($queryParams); $clientEntityMock = $this->createMock(ClientEntityInterface::class); + $clientEntityMock->method('getRegistrationType')->willReturn(RegistrationTypeEnum::Manual); $clientEntityMock->method('getRequestUris')->willReturn([$requestUri]); - $this->clientRepositoryMock->method('getClientEntity') - ->with('client123') - ->willReturn($clientEntityMock); - - $requestObjectBag = $this->createMock(RequestObjectBag::class); - $requestObjectBag->method('get') - ->willReturnMap([ - [RequestObject::class, $this->requestObjectMock], - ]); + $this->clientRepositoryMock->method('getClientEntity')->with('client123')->willReturn($clientEntityMock); + // Fetch is memoized, so the request object is fetched only once across repeated getAll() calls. $this->requestObjectParserMock->expects($this->once()) ->method('fromRequestUri') ->with($requestUri) - ->willReturn($requestObjectBag); + ->willReturn($this->bagWithCore()); $sut = $this->mock($helpersMock); @@ -297,71 +293,106 @@ public function testCanGetAllWithHttpsRequestUriForRegisteredUri(): void array_merge($queryParams, $this->requestObjectParams), $sut->getAll($this->requestMock), ); - - // Resolution is memoized, so the Request Object is not fetched again. $sut->getAll($this->requestMock); - - $this->assertSame( - $requestObjectBag, - $sut->getResolvedRequestUriBag($requestUri), - ); } public function testGetAllDoesNotFetchHttpsRequestUriIfNotRegisteredForClient(): void { $requestUri = 'https://client.example.org/request-object.jwt'; $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'client123']; - - $httpHelperMock = $this->createMock(Helpers\Http::class); - $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); - $helpersMock = $this->createMock(Helpers::class); - $helpersMock->method('http')->willReturn($httpHelperMock); + $helpersMock = $this->helpersWithParams($queryParams); $clientEntityMock = $this->createMock(ClientEntityInterface::class); + $clientEntityMock->method('getRegistrationType')->willReturn(RegistrationTypeEnum::Manual); $clientEntityMock->method('getRequestUris')->willReturn(['https://client.example.org/other.jwt']); $this->clientRepositoryMock->method('getClientEntity')->willReturn($clientEntityMock); $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); - $sut = $this->mock($helpersMock); - - $this->assertSame( - $queryParams, - $sut->getAll($this->requestMock), - ); - - $this->assertNull($sut->getResolvedRequestUriBag($requestUri)); + $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); } - public function testGetAllDoesNotFetchHttpsRequestUriIfClientIdParamIsMissing(): void + public function testGetAllDoesNotFetchHttpsRequestUriIfNotSupported(): void { $requestUri = 'https://client.example.org/request-object.jwt'; - $queryParams = [...$this->queryParams, 'request_uri' => $requestUri]; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'client123']; + $helpersMock = $this->helpersWithParams($queryParams); - $httpHelperMock = $this->createMock(Helpers\Http::class); - $httpHelperMock->method('getAllRequestParams')->willReturn($queryParams); - $helpersMock = $this->createMock(Helpers::class); - $helpersMock->method('http')->willReturn($httpHelperMock); + $moduleConfigMock = $this->createMock(ModuleConfig::class); + $moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(false); + $this->moduleConfigMock = $moduleConfigMock; $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); + $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); + } + + public function testCanFetchHttpsRequestUriForFederationClient(): void + { + $requestUri = 'https://rp.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'https://rp.example.org']; + $helpersMock = $this->helpersWithParams($queryParams); + + // Federation candidate: client not in storage, federation enabled -> fetch is allowed (trust is + // validated after the fetch, in ClientRule). + $this->clientRepositoryMock->method('getClientEntity')->willReturn(null); + $this->moduleConfigMock->method('getFederationEnabled')->willReturn(true); + + $this->requestObjectParserMock->expects($this->once()) + ->method('fromRequestUri') + ->with($requestUri) + ->willReturn($this->bagWithCore()); + $this->assertSame( - $queryParams, + array_merge($queryParams, $this->requestObjectParams), $this->mock($helpersMock)->getAll($this->requestMock), ); } - public function testCanParseRequestObjectBag(): void + public function testDoesNotFetchHttpsRequestUriForUnknownClientWhenFederationDisabled(): void { - $requestObjectBag = new RequestObjectBag(); - $this->requestObjectParserMock->expects($this->once()) - ->method('fromToken') - ->with('token') - ->willReturn($requestObjectBag); + $requestUri = 'https://rp.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'https://rp.example.org']; + $helpersMock = $this->helpersWithParams($queryParams); + + $this->clientRepositoryMock->method('getClientEntity')->willReturn(null); + $this->moduleConfigMock->method('getFederationEnabled')->willReturn(false); + + $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); + + $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); + } + + public function testGetRequestObjectBagForRequestParam(): void + { + $queryParams = [...$this->queryParams, 'request' => 'token']; + $helpersMock = $this->helpersWithParams($queryParams); + + $bag = $this->bagWithCore(); + $this->requestObjectParserMock->method('fromToken')->with('token')->willReturn($bag); $this->assertSame( - $requestObjectBag, - $this->mock()->parseRequestObjectBag('token'), + $bag, + $this->mock($helpersMock)->getRequestObjectBag($this->requestMock, [HttpMethodsEnum::GET]), + ); + } + + public function testGetRequestObjectBagReturnsNullForParUrn(): void + { + $requestUri = PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX . 'abc123'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri]; + + $this->assertNull( + $this->mock($this->helpersWithParams($queryParams)) + ->getRequestObjectBag($this->requestMock, [HttpMethodsEnum::GET]), + ); + } + + public function testGetRequestObjectBagReturnsNullWhenNoSource(): void + { + $this->assertNull( + $this->mock($this->helpersWithParams($this->queryParams)) + ->getRequestObjectBag($this->requestMock, [HttpMethodsEnum::GET]), ); } } From 3964c5e2502917b7cd7fb5e852889a1a32a82ef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Jun 2026 10:50:44 +0200 Subject: [PATCH 06/11] WIP --- config/module_oidc.php.dist | 7 +++ src/ModuleConfig.php | 40 ++++++++++++ src/Utils/RequestParamsResolver.php | 28 ++++++++- tests/unit/src/ModuleConfigTest.php | 42 +++++++++++++ .../src/Utils/RequestParamsResolverTest.php | 63 ++++++++++++++++++- 5 files changed, 176 insertions(+), 4 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 5f642816..58375075 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -131,6 +131,13 @@ $config = [ // OpenID Federation by reference). Set to false to mitigate DoS / SSRF by disabling outbound fetches. Note // that this does not affect Pushed Authorization Request URIs (urn form), which are always supported. ModuleConfig::OPTION_REQUEST_URI_PARAMETER_SUPPORTED => true, // Support https request_uri (default: true) + // SSRF / DoS allowlist for fetching the Request Object by reference for OpenID Federation candidates + // (clients not registered in storage, or registered through OpenID Federation). Registered (non-federation) + // clients are not affected (for them the request_uri must match their registered request_uris exactly). + // - [] (empty array, the default): deny all federation-candidate fetches, + // - ['https://rp.example.org/', ...]: allow only request_uris starting with one of the given prefixes, + // - null: explicitly allow any request_uri for federation candidates (not recommended). + ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES => [], ModuleConfig::OPTION_REQUEST_URI_TIMEOUT => 5, // Timeout for fetching request_uri (default: 5 seconds) ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES => 102400, // Maximum allowed response size for request_uri in bytes (default: 100KB) diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 5c85f2eb..d79e43ae 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -127,6 +127,7 @@ class ModuleConfig final public const string OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS = 'requirePushedAuthorizationRequests'; final public const string OPTION_REQUIRE_SIGNED_REQUEST_OBJECT = 'requireSignedRequestObject'; final public const string OPTION_REQUEST_URI_PARAMETER_SUPPORTED = 'requestUriParameterSupported'; + final public const string OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES = 'federationRequestUriAllowedPrefixes'; final public const string OPTION_REQUEST_URI_TIMEOUT = 'requestUriTimeout'; final public const string OPTION_REQUEST_URI_MAX_SIZE_BYTES = 'requestUriMaxSizeBytes'; @@ -357,6 +358,45 @@ public function getRequestUriParameterSupported(): bool return $this->config()->getOptionalBoolean(self::OPTION_REQUEST_URI_PARAMETER_SUPPORTED, true); } + /** + * Allowed https request_uri prefixes for OpenID Federation candidates (clients not registered in storage, + * or registered through OpenID Federation). For such clients the OP must fetch the Request Object before + * it can establish trust, so this is the SSRF / DoS allowlist for that outbound fetch. Registered + * (non-federation) clients are not affected; for them the request_uri must match their registered + * request_uris exactly. + * + * Semantics: + * - null: explicitly allow any request_uri for federation candidates, + * - non-empty array: allow only request_uris starting with one of the given prefixes, + * - empty array (and the default, when the option is not set): deny all federation-candidate fetches. + * + * @return string[]|null + */ + public function getFederationRequestUriAllowedPrefixes(): ?array + { + // Note: we read the raw config here (instead of getOptionalValue) so we can distinguish an explicit + // null (allow any) from an absent option (deny by default), since SimpleSAML\Configuration treats a + // null value the same as an absent one. + $config = $this->config()->toArray(); + + if (!array_key_exists(self::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES, $config)) { + return []; + } + + /** @var mixed $value */ + $value = $config[self::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES]; + + if (is_null($value)) { + return null; + } + + if (!is_array($value)) { + return []; + } + + return array_values(array_filter($value, 'is_string')); + } + public function getRequestUriTimeout(): int { return $this->config()->getOptionalInteger(self::OPTION_REQUEST_URI_TIMEOUT, 5); diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index 1196ee0e..c5c43127 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -374,7 +374,8 @@ protected function fetchRequestObjectBagByUri(string $requestUri, array $request * - for registered (non-federation) clients, the request_uri must be pre-registered in the client's * request_uris (RFC 9126 exact-matching), * - for clients not in storage or registered through OpenID Federation, fetching is allowed when - * federation is enabled (trust is validated after the fetch, in ClientRule). + * federation is enabled and the request_uri is allowed by the federation request_uri prefix allowlist + * (trust is validated after the fetch, in ClientRule). */ protected function isHttpsRequestUriFetchAllowed(string $requestUri, array $requestParams): bool { @@ -400,7 +401,30 @@ protected function isHttpsRequestUriFetchAllowed(string $requestUri, array $requ } // Client not in storage, or registered through OpenID Federation: federation by-reference path. - return $this->moduleConfig->getFederationEnabled(); + return $this->moduleConfig->getFederationEnabled() && + $this->isFederationRequestUriAllowed($requestUri); + } + + /** + * Check the federation request_uri against the configured prefix allowlist (SSRF / DoS mitigation for the + * outbound fetch of a not-yet-trusted federation candidate's Request Object). + */ + protected function isFederationRequestUriAllowed(string $requestUri): bool + { + $allowedPrefixes = $this->moduleConfig->getFederationRequestUriAllowedPrefixes(); + + // Null means explicitly allow any request_uri. + if (is_null($allowedPrefixes)) { + return true; + } + + foreach ($allowedPrefixes as $allowedPrefix) { + if ($allowedPrefix !== '' && str_starts_with($requestUri, $allowedPrefix)) { + return true; + } + } + + return false; } /** diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 80e090a6..b9feca89 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -415,6 +415,48 @@ public function testCanGetProtocolCacheConfiguration(): void $this->assertInstanceOf(DateInterval::class, $this->sut()->getProtocolClientEntityCacheDuration()); } + public function testCanGetRequestUriParameterSupported(): void + { + // Default. + $this->assertTrue($this->sut()->getRequestUriParameterSupported()); + + $this->assertFalse( + $this->sut( + overrides: [ModuleConfig::OPTION_REQUEST_URI_PARAMETER_SUPPORTED => false], + )->getRequestUriParameterSupported(), + ); + } + + public function testGetFederationRequestUriAllowedPrefixesDeniesByDefault(): void + { + // Option absent -> deny all federation-candidate fetches (empty allowlist). + $this->assertSame([], $this->sut()->getFederationRequestUriAllowedPrefixes()); + } + + public function testGetFederationRequestUriAllowedPrefixesCanAllowAny(): void + { + // Explicit null -> allow any. + $this->assertNull( + $this->sut( + overrides: [ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES => null], + )->getFederationRequestUriAllowedPrefixes(), + ); + } + + public function testGetFederationRequestUriAllowedPrefixesReturnsConfiguredPrefixes(): void + { + $sut = $this->sut( + overrides: [ + ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES => [ + 'https://rp.example.org/', + 123, // non-string values are filtered out + ], + ], + ); + + $this->assertSame(['https://rp.example.org/'], $sut->getFederationRequestUriAllowedPrefixes()); + } + public function testCanGetProtocolDiscoveryShowClaimsSupported(): void { $this->assertFalse($this->sut()->getProtocolDiscoveryShowClaimsSupported()); diff --git a/tests/unit/src/Utils/RequestParamsResolverTest.php b/tests/unit/src/Utils/RequestParamsResolverTest.php index fbe6a94c..0308335d 100644 --- a/tests/unit/src/Utils/RequestParamsResolverTest.php +++ b/tests/unit/src/Utils/RequestParamsResolverTest.php @@ -333,10 +333,11 @@ public function testCanFetchHttpsRequestUriForFederationClient(): void $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'https://rp.example.org']; $helpersMock = $this->helpersWithParams($queryParams); - // Federation candidate: client not in storage, federation enabled -> fetch is allowed (trust is - // validated after the fetch, in ClientRule). + // Federation candidate: client not in storage, federation enabled, request_uri allowed (null = allow + // any) -> fetch is allowed (trust is validated after the fetch, in ClientRule). $this->clientRepositoryMock->method('getClientEntity')->willReturn(null); $this->moduleConfigMock->method('getFederationEnabled')->willReturn(true); + $this->moduleConfigMock->method('getFederationRequestUriAllowedPrefixes')->willReturn(null); $this->requestObjectParserMock->expects($this->once()) ->method('fromRequestUri') @@ -349,6 +350,64 @@ public function testCanFetchHttpsRequestUriForFederationClient(): void ); } + public function testCanFetchHttpsRequestUriForFederationClientWithAllowedPrefix(): void + { + $requestUri = 'https://rp.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'https://rp.example.org']; + $helpersMock = $this->helpersWithParams($queryParams); + + $this->clientRepositoryMock->method('getClientEntity')->willReturn(null); + $this->moduleConfigMock->method('getFederationEnabled')->willReturn(true); + $this->moduleConfigMock->method('getFederationRequestUriAllowedPrefixes') + ->willReturn(['https://rp.example.org/']); + + $this->requestObjectParserMock->expects($this->once()) + ->method('fromRequestUri') + ->with($requestUri) + ->willReturn($this->bagWithCore()); + + $this->assertSame( + array_merge($queryParams, $this->requestObjectParams), + $this->mock($helpersMock)->getAll($this->requestMock), + ); + } + + public function testDoesNotFetchHttpsRequestUriForFederationClientWithDisallowedPrefix(): void + { + $requestUri = 'https://attacker.example.org/request-object.jwt'; + $queryParams = [ + ...$this->queryParams, + 'request_uri' => $requestUri, + 'client_id' => 'https://attacker.example.org', + ]; + $helpersMock = $this->helpersWithParams($queryParams); + + $this->clientRepositoryMock->method('getClientEntity')->willReturn(null); + $this->moduleConfigMock->method('getFederationEnabled')->willReturn(true); + $this->moduleConfigMock->method('getFederationRequestUriAllowedPrefixes') + ->willReturn(['https://rp.example.org/']); + + $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); + + $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); + } + + public function testDoesNotFetchHttpsRequestUriForFederationClientWhenPrefixListIsEmpty(): void + { + $requestUri = 'https://rp.example.org/request-object.jwt'; + $queryParams = [...$this->queryParams, 'request_uri' => $requestUri, 'client_id' => 'https://rp.example.org']; + $helpersMock = $this->helpersWithParams($queryParams); + + $this->clientRepositoryMock->method('getClientEntity')->willReturn(null); + $this->moduleConfigMock->method('getFederationEnabled')->willReturn(true); + // Empty allowlist (the default) denies all federation-candidate fetches. + $this->moduleConfigMock->method('getFederationRequestUriAllowedPrefixes')->willReturn([]); + + $this->requestObjectParserMock->expects($this->never())->method('fromRequestUri'); + + $this->assertSame($queryParams, $this->mock($helpersMock)->getAll($this->requestMock)); + } + public function testDoesNotFetchHttpsRequestUriForUnknownClientWhenFederationDisabled(): void { $requestUri = 'https://rp.example.org/request-object.jwt'; From 7d820310d594ce0702b6d8443233754293dbca2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Jun 2026 11:47:19 +0200 Subject: [PATCH 07/11] WIP --- config/module_oidc.php.dist | 100 +++++++++++++++++++++++----- src/ModuleConfig.php | 18 ++--- src/Utils/RequestParamsResolver.php | 2 +- 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index 58375075..b3a45d5c 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -121,25 +121,70 @@ $config = [ ModuleConfig::OPTION_TIMESTAMP_VALIDATION_LEEWAY => 'PT1M', /** - * Pushed Authorization Request (PAR) and Request Object URL (JAR) - * configurations. + * Pushed Authorization Request (PAR) request URI expiration TTL. + * This is the time for which a PAR request URI will be valid. + * + * For duration format info, check + * https://www.php.net/manual/en/dateinterval.construct.php + * + * Default: PT10M (10 minutes) */ - ModuleConfig::OPTION_PAR_REQUEST_URI_TTL => 'PT10M', // PAR request URI expiration TTL (default: 10 minutes) - ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS => false, // Require PAR globally (default: false) - ModuleConfig::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT => false, // Reject unsigned request objects globally (default: false) - // Whether to support passing the Request Object by reference using the https request_uri parameter (JAR / - // OpenID Federation by reference). Set to false to mitigate DoS / SSRF by disabling outbound fetches. Note - // that this does not affect Pushed Authorization Request URIs (urn form), which are always supported. - ModuleConfig::OPTION_REQUEST_URI_PARAMETER_SUPPORTED => true, // Support https request_uri (default: true) - // SSRF / DoS allowlist for fetching the Request Object by reference for OpenID Federation candidates - // (clients not registered in storage, or registered through OpenID Federation). Registered (non-federation) - // clients are not affected (for them the request_uri must match their registered request_uris exactly). - // - [] (empty array, the default): deny all federation-candidate fetches, - // - ['https://rp.example.org/', ...]: allow only request_uris starting with one of the given prefixes, - // - null: explicitly allow any request_uri for federation candidates (not recommended). - ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES => [], - ModuleConfig::OPTION_REQUEST_URI_TIMEOUT => 5, // Timeout for fetching request_uri (default: 5 seconds) - ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES => 102400, // Maximum allowed response size for request_uri in bytes (default: 100KB) + ModuleConfig::OPTION_PAR_REQUEST_URI_TTL => 'PT10M', // 10 minutes + + /** + * Require Pushed Authorization Request (PAR) globally. If set to true, + * the OP will reject authorization requests which do not use PAR, i.e., + * requests which do not have a valid request_uri corresponding + * to a previously pushed authorization request. + * + * Default: false + */ + ModuleConfig::OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS => false, + + /** + * Whether to support passing the Request Object by reference using the + * https request_uri parameter (JAR / OpenID Federation) by reference. + * Note that the client must have its request URIs registered beforehand. + * For OpenID Federation Automatic Registration, see the option + * OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES. + * + * Set to `false` to remove any risk of DoS / SSRF attacks by disabling + * outbound fetches to given request URIs. Note that this does not + * affect Pushed Authorization Request URIs (urn form), which are + * always supported. + * + * Default: true + */ + ModuleConfig::OPTION_REQUEST_URI_PARAMETER_SUPPORTED => true, + + /** + * Timeout for fetching request_uri, in seconds. + * + * Default: 5 seconds + */ + ModuleConfig::OPTION_REQUEST_URI_FETCH_TIMEOUT => 5, + + /** + * Maximum allowed response size for request_uri, in bytes. + * + * Default: 102400 bytes (100KB) + */ + ModuleConfig::OPTION_REQUEST_URI_MAX_SIZE_BYTES => 102400, + + /** + * Enforces signature validation for all Request Objects. OpenID Connect + * Core allows Request Objects to be unsigned, but when this option is + * set to true, the OP will reject any Request Object that does not + * contain a valid signature. + * + * Note: in order for this to work, all Relying Parties must support + * signing Request Objects, and the OP must have their corresponding + * signing keys configured. Of course, this is only relevant if they + * use Request Object in authorization requests. + * + * Default: false + */ + ModuleConfig::OPTION_REQUIRE_SIGNED_REQUEST_OBJECT => false, /** * The default authentication source to be used for authentication if the @@ -670,6 +715,25 @@ $config = [ ModuleConfig::OPTION_INFORMATION_URI => null, ModuleConfig::OPTION_ORGANIZATION_URI => null, + /** + * Allowlist for fetching the Request Object by reference for OpenID + * Federation candidates (clients not yet registered in storage). + * Registered (non-federation) clients are not affected with + * this option (for them the request_uri must match their + * registered request_uris exactly). + * + * Examples: + * - [] (empty array, the default): deny all federation-candidate fetches, + * - ['https://rp.example.org/', ...]: allow only request_uris starting with one of the given prefixes, + * - null: explicitly allow any request_uri for federation candidates (not recommended). + * + * Note: prefixes should be specific enough to avoid false positives + * (e.g. https://rp.example.org/ with the trailing slash). A loose + * prefix like 'https://' would defeat the purpose, at which point + * `null` is the clearer way to say "allow any." + */ + ModuleConfig::OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES => [], + /*************************************************************************** * (optional) OpenID Verifiable Credential related options. If these are diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index d79e43ae..6275a606 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -123,13 +123,13 @@ class ModuleConfig final public const string OPTION_VCI_SIGNATURE_KEY_PAIRS = 'vci_signature_key_pairs'; final public const string OPTION_VCI_CREDENTIAL_JSON_LD_CONTEXT = 'vci_credential_json_ld_context'; - final public const string OPTION_PAR_REQUEST_URI_TTL = 'parRequestUriDuration'; - final public const string OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS = 'requirePushedAuthorizationRequests'; - final public const string OPTION_REQUIRE_SIGNED_REQUEST_OBJECT = 'requireSignedRequestObject'; - final public const string OPTION_REQUEST_URI_PARAMETER_SUPPORTED = 'requestUriParameterSupported'; - final public const string OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES = 'federationRequestUriAllowedPrefixes'; - final public const string OPTION_REQUEST_URI_TIMEOUT = 'requestUriTimeout'; - final public const string OPTION_REQUEST_URI_MAX_SIZE_BYTES = 'requestUriMaxSizeBytes'; + final public const string OPTION_PAR_REQUEST_URI_TTL = 'par_request_uri_ttl'; + final public const string OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS = 'require_pushed_authorization_requests'; + final public const string OPTION_REQUIRE_SIGNED_REQUEST_OBJECT = 'require_signed_request_object'; + final public const string OPTION_REQUEST_URI_PARAMETER_SUPPORTED = 'request_uri_parameter_supported'; + final public const string OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES = 'federation_request_uri_allowed_prefixes'; + final public const string OPTION_REQUEST_URI_FETCH_TIMEOUT = 'request_uri_fetch_timeout'; + final public const string OPTION_REQUEST_URI_MAX_SIZE_BYTES = 'request_uri_max_size_bytes'; protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ @@ -397,9 +397,9 @@ public function getFederationRequestUriAllowedPrefixes(): ?array return array_values(array_filter($value, 'is_string')); } - public function getRequestUriTimeout(): int + public function getRequestUriFetchTimeout(): int { - return $this->config()->getOptionalInteger(self::OPTION_REQUEST_URI_TIMEOUT, 5); + return $this->config()->getOptionalInteger(self::OPTION_REQUEST_URI_FETCH_TIMEOUT, 5); } public function getRequestUriMaxSizeBytes(): int diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index c5c43127..26aebba8 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -355,7 +355,7 @@ protected function fetchRequestObjectBagByUri(string $requestUri, array $request return $this->requestObjectBagsByUri[$requestUri] = $this->requestObject->requestObjectParser() ->fromRequestUri( $requestUri, - $this->moduleConfig->getRequestUriTimeout(), + $this->moduleConfig->getRequestUriFetchTimeout(), $this->moduleConfig->getRequestUriMaxSizeBytes(), ); } catch (\Throwable $throwable) { From 939343c59b5e8ecfe1909a5f15f366387e28f38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Jun 2026 13:24:36 +0200 Subject: [PATCH 08/11] WIP --- src/ModuleConfig.php | 3 ++- src/Server/RequestRules/Rules/ClientRule.php | 7 ++++--- .../RequestRules/Rules/RequestObjectRule.php | 8 +++---- src/Utils/RequestParamsResolver.php | 21 ++++++++++++------- .../src/Utils/RequestParamsResolverTest.php | 2 +- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 6275a606..79746d1a 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -127,7 +127,8 @@ class ModuleConfig final public const string OPTION_REQUIRE_PUSHED_AUTHORIZATION_REQUESTS = 'require_pushed_authorization_requests'; final public const string OPTION_REQUIRE_SIGNED_REQUEST_OBJECT = 'require_signed_request_object'; final public const string OPTION_REQUEST_URI_PARAMETER_SUPPORTED = 'request_uri_parameter_supported'; - final public const string OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES = 'federation_request_uri_allowed_prefixes'; + final public const string OPTION_FEDERATION_REQUEST_URI_ALLOWED_PREFIXES = + 'federation_request_uri_allowed_prefixes'; final public const string OPTION_REQUEST_URI_FETCH_TIMEOUT = 'request_uri_fetch_timeout'; final public const string OPTION_REQUEST_URI_MAX_SIZE_BYTES = 'request_uri_max_size_bytes'; diff --git a/src/Server/RequestRules/Rules/ClientRule.php b/src/Server/RequestRules/Rules/ClientRule.php index 2ea4635d..aef34bb6 100644 --- a/src/Server/RequestRules/Rules/ClientRule.php +++ b/src/Server/RequestRules/Rules/ClientRule.php @@ -162,8 +162,9 @@ public function resolveFromFederation( ): ?ClientEntityInterface { $this->loggerService->debug('ClientRule: Resolving client from federation.'); // Federation is enabled. - // Check if we have a Request Object available, either passed by value (request param) or by reference - // (https request_uri param). The RequestParamsResolver does the heavy lifting (parsing / fetching). + // Check if we have a Request Object available, either passed by value + // (request param) or by reference (https request_uri param). The + // RequestParamsResolver does the heavy lifting (parsing / fetching). // If not available, we don't have anything else to do. $requestObjectBag = $this->requestParamsResolver->getRequestObjectBag($request, $allowedMethods); @@ -173,7 +174,7 @@ public function resolveFromFederation( } // We must verify that the Request Object is the one compatible with OpenID Federation specification - // (not only Core specification). + // (not only Connect Core or OAuth2 JAR specification). $requestObject = $requestObjectBag->get(FederationRequestObject::class); if (!$requestObject instanceof FederationRequestObject) { diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index 6acfefb5..1497ae72 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -66,10 +66,6 @@ public function checkRule( return null; } - // Parse it using all available Request Object flavors, so we can differentiate between OpenID Connect - // Core Request Objects (which can be unsigned) and JAR Request Objects (which must be signed). - $requestObjectBag = $this->requestParamsResolver->getRequestObjectBag($request, $allowedServerRequestMethods); - /** @var \SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface $client */ $client = $currentResultBag->getOrFail(ClientRule::class)->getValue(); /** @var string $redirectUri */ @@ -77,6 +73,10 @@ public function checkRule( /** @var ?string $stateValue */ $stateValue = ($currentResultBag->get(StateRule::class))?->getValue(); + // Parse it using all available Request Object flavors, so we can differentiate between OpenID Connect + // Core Request Objects (which can be unsigned) and JAR Request Objects (which must be signed). + $requestObjectBag = $this->requestParamsResolver->getRequestObjectBag($request, $allowedServerRequestMethods); + // The Request Object source is present, but it could not be parsed (by value) or fetched/parsed (by // reference). Note that for the by-reference case, RequestUriRule would normally reject this earlier. if ($requestObjectBag === null) { diff --git a/src/Utils/RequestParamsResolver.php b/src/Utils/RequestParamsResolver.php index 26aebba8..bf44db12 100644 --- a/src/Utils/RequestParamsResolver.php +++ b/src/Utils/RequestParamsResolver.php @@ -315,11 +315,14 @@ public function getRequestObjectBag( } /** - * Parse (memoized) the Request Object token using all available Request Object flavors (OpenID Connect - * Core, JAR, OpenID Federation). The returned bag contains an entry for every flavor for which the token - * parsed and passed flavor-specific validation, so it can be used to differentiate between, for example, - * OpenID Connect Core Request Objects (which can be unsigned) and JAR Request Objects (which must be - * signed). Note that this won't do signature validation. + * Parse (memoized) the Request Object token using all available Request + * Object flavors (OpenID Connect Core, JAR, OpenID Federation). The + * returned bag contains an entry for every flavor for which the + * token parsed and passed flavor-specific validation, so it can + * be used to differentiate between, for example, OpenID Connect + * Core Request Objects (which can be unsigned) and JAR Request + * Objects (which must be signed). Note that this won't do + * signature validation. */ protected function parseRequestObjectBagByToken(string $token): ?RequestObjectBag { @@ -339,7 +342,8 @@ protected function parseRequestObjectBagByToken(string $token): ?RequestObjectBa } /** - * Fetch and parse (memoized) the Request Object from the given https Request URI, if allowed by policy. + * Fetch and parse (memoized) the Request Object from the given https + * Request URI, if allowed by policy. */ protected function fetchRequestObjectBagByUri(string $requestUri, array $requestParams): ?RequestObjectBag { @@ -368,8 +372,9 @@ protected function fetchRequestObjectBagByUri(string $requestUri, array $request } /** - * Decide whether an https Request URI (Request Object by reference) is allowed to be fetched. This is the - * single authorization point for outbound Request Object fetches (SSRF / DoS surface): + * Decide whether a https Request URI (Request Object by reference) is + * allowed to be fetched. This is the single authorization point for + * outbound Request Object fetches (SSRF / DoS surface): * - the OP must support the request_uri parameter (request_uri_parameter_supported), * - for registered (non-federation) clients, the request_uri must be pre-registered in the client's * request_uris (RFC 9126 exact-matching), diff --git a/tests/unit/src/Utils/RequestParamsResolverTest.php b/tests/unit/src/Utils/RequestParamsResolverTest.php index 0308335d..136206d4 100644 --- a/tests/unit/src/Utils/RequestParamsResolverTest.php +++ b/tests/unit/src/Utils/RequestParamsResolverTest.php @@ -80,7 +80,7 @@ protected function setUp(): void ->willReturn($this->requestObjectParserMock); $this->moduleConfigMock = $this->createMock(ModuleConfig::class); $this->moduleConfigMock->method('getRequestUriParameterSupported')->willReturn(true); - $this->moduleConfigMock->method('getRequestUriTimeout')->willReturn(5); + $this->moduleConfigMock->method('getRequestUriFetchTimeout')->willReturn(5); $this->moduleConfigMock->method('getRequestUriMaxSizeBytes')->willReturn(102400); $this->clientRepositoryMock = $this->createMock(ClientRepository::class); $this->pushedAuthorizationRequestRepositoryMock = $this->createMock( From 294c851dd4f194dde6bee41cc17d845d12ea647b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Jun 2026 13:54:09 +0200 Subject: [PATCH 09/11] WIP --- src/Controllers/PushedAuthorizationController.php | 2 +- .../PushedAuthorizationRequestEntityFactory.php | 5 +++-- .../PushedAuthorizationControllerTest.php | 4 ++-- ...PushedAuthorizationRequestEntityFactoryTest.php | 6 +++--- .../PushedAuthorizationRequestRepositoryTest.php | 14 +++++++------- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/Controllers/PushedAuthorizationController.php b/src/Controllers/PushedAuthorizationController.php index 9c579d79..ac118136 100644 --- a/src/Controllers/PushedAuthorizationController.php +++ b/src/Controllers/PushedAuthorizationController.php @@ -115,7 +115,7 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface $parameters = $this->resolveParametersToPersist($resultBag, $bodyParams, $client->getIdentifier()); - $parEntity = $this->pushedAuthorizationRequestEntityFactory->buildNew( + $parEntity = $this->pushedAuthorizationRequestEntityFactory->fromData( $client->getIdentifier(), $parameters, ); diff --git a/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php b/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php index 02a59f4a..40f1bbbb 100644 --- a/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php +++ b/src/Factories/Entities/PushedAuthorizationRequestEntityFactory.php @@ -33,12 +33,13 @@ public function __construct( * @param mixed[] $parameters * @throws \Exception */ - public function buildNew( + public function fromData( string $clientId, array $parameters, ?DateTimeImmutable $expiresAt = null, ): PushedAuthorizationRequestEntity { - $requestUri = self::REQUEST_URI_PREFIX . bin2hex(random_bytes(32)); + + $requestUri = self::REQUEST_URI_PREFIX . $this->helpers->random()->getIdentifier(32); $expiresAt ??= $this->helpers->dateTime()->getUtc() ->add($this->moduleConfig->getParRequestUriTtl()); diff --git a/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php index 4f927740..b2f6b39c 100644 --- a/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php +++ b/tests/unit/src/Controllers/PushedAuthorizationControllerTest.php @@ -200,7 +200,7 @@ public function testHandlesValidParRequest(): void // Client authentication params must not be persisted, while client_id is bound to the // authenticated client. $this->pushedAuthorizationRequestEntityFactoryMock->expects($this->once()) - ->method('buildNew') + ->method('fromData') ->with( 'client123', [ @@ -246,7 +246,7 @@ public function testPersistsRequestObjectPayloadOnlyWhenJarIsUsed(): void $this->resultBagMock->method('getOrFail')->with(RequestObjectRule::class)->willReturn($requestObjectResult); $this->pushedAuthorizationRequestEntityFactoryMock->expects($this->once()) - ->method('buildNew') + ->method('fromData') ->with('client123', $requestObjectPayload) ->willReturn($this->parEntityMock); diff --git a/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php b/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php index 1b623094..ad55707d 100644 --- a/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php +++ b/tests/unit/src/Factories/Entities/PushedAuthorizationRequestEntityFactoryTest.php @@ -47,7 +47,7 @@ public function testCanBuildNew(): void { $parameters = ['client_id' => 'client123', 'response_type' => 'code']; - $entity = $this->sut()->buildNew('client123', $parameters); + $entity = $this->sut()->fromData('client123', $parameters); $this->assertStringStartsWith( PushedAuthorizationRequestEntityFactory::REQUEST_URI_PREFIX, @@ -75,8 +75,8 @@ public function testBuildNewGeneratesUniqueRequestUris(): void $sut = $this->sut(); $this->assertNotSame( - $sut->buildNew('client123', [])->getRequestUri(), - $sut->buildNew('client123', [])->getRequestUri(), + $sut->fromData('client123', [])->getRequestUri(), + $sut->fromData('client123', [])->getRequestUri(), ); } diff --git a/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php b/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php index 52ad7803..da2e0071 100644 --- a/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php +++ b/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php @@ -73,7 +73,7 @@ public function testGetTableName(): void public function testCanPersistAndFind(): void { $parameters = ['client_id' => 'client123', 'response_type' => 'code']; - $entity = $this->entityFactory->buildNew('client123', $parameters); + $entity = $this->entityFactory->fromData('client123', $parameters); $this->repository->persist($entity); @@ -99,7 +99,7 @@ public function testFindReturnsNullForUnknownRequestUri(): void public function testFindValidReturnsEntityForValidRequestUri(): void { - $entity = $this->entityFactory->buildNew('client123', []); + $entity = $this->entityFactory->fromData('client123', []); $this->repository->persist($entity); $this->assertInstanceOf( @@ -110,7 +110,7 @@ public function testFindValidReturnsEntityForValidRequestUri(): void public function testFindValidReturnsNullForExpiredRequestUri(): void { - $entity = $this->entityFactory->buildNew( + $entity = $this->entityFactory->fromData( 'client123', [], $this->helpers->dateTime()->getUtc()->sub(new DateInterval('PT1M')), @@ -122,7 +122,7 @@ public function testFindValidReturnsNullForExpiredRequestUri(): void public function testFindValidReturnsNullForConsumedRequestUri(): void { - $entity = $this->entityFactory->buildNew('client123', []); + $entity = $this->entityFactory->fromData('client123', []); $this->repository->persist($entity); $this->assertTrue($this->repository->consume($entity->getRequestUri())); @@ -132,7 +132,7 @@ public function testFindValidReturnsNullForConsumedRequestUri(): void public function testConsumeReturnsTrueOnlyOnce(): void { - $entity = $this->entityFactory->buildNew('client123', []); + $entity = $this->entityFactory->fromData('client123', []); $this->repository->persist($entity); $this->assertTrue($this->repository->consume($entity->getRequestUri())); @@ -149,13 +149,13 @@ public function testConsumeReturnsFalseForUnknownRequestUri(): void public function testCanRemoveExpired(): void { - $expiredEntity = $this->entityFactory->buildNew( + $expiredEntity = $this->entityFactory->fromData( 'client123', [], $this->helpers->dateTime()->getUtc()->sub(new DateInterval('PT1M')), ); $this->repository->persist($expiredEntity); - $validEntity = $this->entityFactory->buildNew('client123', []); + $validEntity = $this->entityFactory->fromData('client123', []); $this->repository->persist($validEntity); $this->repository->removeExpired(); From 15b88a8a6cc91652ee48f3cfd0b3d2aaae5dbb19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Jun 2026 14:06:45 +0200 Subject: [PATCH 10/11] WIP --- .../PushedAuthorizationRequestRepository.php | 53 +++++++++---- ...shedAuthorizationRequestRepositoryTest.php | 75 +++++++++++++++++++ 2 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/Repositories/PushedAuthorizationRequestRepository.php b/src/Repositories/PushedAuthorizationRequestRepository.php index 73d4fd04..e51263d2 100644 --- a/src/Repositories/PushedAuthorizationRequestRepository.php +++ b/src/Repositories/PushedAuthorizationRequestRepository.php @@ -51,10 +51,16 @@ public function persist(PushedAuthorizationRequestEntity $entity): void $stmt = "INSERT INTO {$this->getTableName()} (request_uri, client_id, parameters, expires_at, is_consumed) " . "VALUES (:request_uri, :client_id, :parameters, :expires_at, :is_consumed)"; - $state = $entity->getState(); - $state['is_consumed'] = (int)$state['is_consumed']; + $params = $entity->getState(); + $params['is_consumed'] = (int)$params['is_consumed']; - $this->database->write($stmt, $state); + $this->database->write($stmt, $params); + + $this->protocolCache?->set( + $entity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime($entity->getExpiresAt()->getTimestamp()), + $this->getCacheKey($entity->getRequestUri()), + ); } /** @@ -66,17 +72,30 @@ public function persist(PushedAuthorizationRequestEntity $entity): void */ public function find(string $requestUri): ?PushedAuthorizationRequestEntity { - $stmt = $this->database->read( - "SELECT request_uri, client_id, parameters, expires_at, is_consumed " . - "FROM {$this->getTableName()} WHERE request_uri = :request_uri", - ['request_uri' => $requestUri], - ); - - if (!is_array($row = $stmt->fetch(PDO::FETCH_ASSOC))) { - return null; + /** @var ?array $state */ + $state = $this->protocolCache?->get(null, $this->getCacheKey($requestUri)); + + if (!is_array($state)) { + $stmt = $this->database->read( + "SELECT request_uri, client_id, parameters, expires_at, is_consumed " . + "FROM {$this->getTableName()} WHERE request_uri = :request_uri", + ['request_uri' => $requestUri], + ); + + if (!is_array($state = $stmt->fetch(PDO::FETCH_ASSOC))) { + return null; + } } - return $this->pushedAuthorizationRequestEntityFactory->fromState($row); + $entity = $this->pushedAuthorizationRequestEntityFactory->fromState($state); + + $this->protocolCache?->set( + $entity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime($entity->getExpiresAt()->getTimestamp()), + $this->getCacheKey($entity->getRequestUri()), + ); + + return $entity; } /** @@ -106,8 +125,11 @@ public function findValid(string $requestUri): ?PushedAuthorizationRequestEntity } /** - * Mark the Pushed Authorization Request as consumed (one-time use). Atomic, so it can be used as a replay - * guard: returns true only if this call was the one that consumed it. + * Mark the Pushed Authorization Request as consumed (one-time use). Atomic, + * so it can be used as a replay guard: returns true only if this call was + * the one that consumed it. Note that the database is the source of truth + * for consumption (the protocol cache is only a read accelerator), so a + * stale cached entry can never enable a replay. */ public function consume(string $requestUri): bool { @@ -116,6 +138,9 @@ public function consume(string $requestUri): bool $affected = $this->database->write($stmt, ['request_uri' => $requestUri]); + // Invalidate the cached entry, so subsequent finds reflect the consumed state. + $this->protocolCache?->delete($this->getCacheKey($requestUri)); + return is_int($affected) && $affected > 0; } diff --git a/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php b/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php index da2e0071..9191a7be 100644 --- a/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php +++ b/tests/unit/src/Repositories/PushedAuthorizationRequestRepositoryTest.php @@ -17,6 +17,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\PushedAuthorizationRequestRepository; use SimpleSAML\Module\oidc\Services\DatabaseMigration; +use SimpleSAML\Module\oidc\Utils\ProtocolCache; #[CoversClass(PushedAuthorizationRequestRepository::class)] #[UsesClass(PushedAuthorizationRequestEntity::class)] @@ -166,4 +167,78 @@ public function testCanRemoveExpired(): void $this->repository->find($validEntity->getRequestUri()), ); } + + protected function repositoryWithCache(MockObject $protocolCacheMock): PushedAuthorizationRequestRepository + { + return new PushedAuthorizationRequestRepository( + $this->moduleConfigMock, + Database::getInstance(), + $protocolCacheMock, + $this->entityFactory, + $this->helpers, + ); + } + + public function testPersistStoresEntityInCache(): void + { + $entity = $this->entityFactory->fromData('client123', []); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->expects($this->once())->method('set') + ->with( + $entity->getState(), + $this->callback('is_int'), + 'phpunit_oidc_par_' . $entity->getRequestUri(), + ); + + $this->repositoryWithCache($protocolCacheMock)->persist($entity); + } + + public function testFindCanReturnEntityFromCache(): void + { + // Note: this entity is intentionally not persisted to database, so a successful find proves the + // cache path was used. + $entity = $this->entityFactory->fromData('client123', ['response_type' => 'code']); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->method('get')->willReturn($entity->getState()); + + $foundEntity = $this->repositoryWithCache($protocolCacheMock)->find($entity->getRequestUri()); + + $this->assertInstanceOf(PushedAuthorizationRequestEntity::class, $foundEntity); + $this->assertSame($entity->getRequestUri(), $foundEntity->getRequestUri()); + $this->assertSame(['response_type' => 'code'], $foundEntity->getParameters()); + } + + public function testFindCachesEntityResolvedFromDatabase(): void + { + $entity = $this->entityFactory->fromData('client123', []); + $this->repository->persist($entity); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->method('get')->willReturn(null); + $protocolCacheMock->expects($this->once())->method('set') + ->with( + $entity->getState(), + $this->callback('is_int'), + 'phpunit_oidc_par_' . $entity->getRequestUri(), + ); + + $this->assertInstanceOf( + PushedAuthorizationRequestEntity::class, + $this->repositoryWithCache($protocolCacheMock)->find($entity->getRequestUri()), + ); + } + + public function testConsumeInvalidatesCache(): void + { + $entity = $this->entityFactory->fromData('client123', []); + $this->repository->persist($entity); + + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->expects($this->once())->method('delete') + ->with('phpunit_oidc_par_' . $entity->getRequestUri()); + + $this->assertTrue($this->repositoryWithCache($protocolCacheMock)->consume($entity->getRequestUri())); + } } From 76cbf4fd61c53be144fb165061ef26116fefb78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 12 Jun 2026 14:57:27 +0200 Subject: [PATCH 11/11] WIP --- .../RequestRules/Rules/RequestObjectRule.php | 84 ++++++++++++- src/Services/DatabaseMigration.php | 4 +- .../Rules/RequestObjectRuleTest.php | 111 ++++++++++++++++++ 3 files changed, 195 insertions(+), 4 deletions(-) diff --git a/src/Server/RequestRules/Rules/RequestObjectRule.php b/src/Server/RequestRules/Rules/RequestObjectRule.php index 1497ae72..c07372ee 100644 --- a/src/Server/RequestRules/Rules/RequestObjectRule.php +++ b/src/Server/RequestRules/Rules/RequestObjectRule.php @@ -119,6 +119,9 @@ public function checkRule( $this->verifySignature($jarRequestObject, $client, $redirectUri, $stateValue, $responseMode); + $this->verifyAudience($jarRequestObject, $redirectUri, $stateValue, $responseMode); + $this->verifyIssuer($jarRequestObject, $client, $redirectUri, $stateValue, $responseMode); + return new Result($this->getKey(), $jarRequestObject->getPayload()); } @@ -151,11 +154,13 @@ public function checkRule( $responseMode, ); } - return new Result($this->getKey(), $requestObject->getPayload()); + } else { + // It is protected, we must validate the signature. + $this->verifySignature($requestObject, $client, $redirectUri, $stateValue, $responseMode); } - // It is protected, we must validate it. - $this->verifySignature($requestObject, $client, $redirectUri, $stateValue, $responseMode); + $this->verifyAudience($requestObject, $redirectUri, $stateValue, $responseMode); + $this->verifyIssuer($requestObject, $client, $redirectUri, $stateValue, $responseMode); return new Result($this->getKey(), $requestObject->getPayload()); } @@ -190,6 +195,79 @@ protected function hasRequestObjectSource( return is_string($requestUri) && str_starts_with(strtolower($requestUri), 'https://'); } + /** + * Validate the Request Object audience (aud) claim. + * + * Unlike the OpenID Federation flavor (handled in ClientRule, where aud is mandatory), the aud claim is + * optional for OpenID Connect Core and JAR (RFC 9101) Request Objects. We therefore only validate it when + * a client actually includes it: in that case it MUST identify this OP (its Issuer Identifier). Rejecting + * a mismatch prevents a Request Object minted for a different Authorization Server from being replayed + * here (OAuth 2.0 Security BCP). An absent aud is tolerated to preserve interoperability. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function verifyAudience( + ConnectRequestObject|JarRequestObject $requestObject, + string $redirectUri, + ?string $stateValue, + ResponseModeInterface $responseMode, + ): void { + $audience = $requestObject->getAudience(); + + // The claim is optional for these flavors; only validate it when present. + if ($audience === null) { + return; + } + + if (!in_array($this->moduleConfig->getIssuer(), $audience, true)) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object audience (aud) does not include this OP issuer.', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + } + + /** + * Validate the Request Object issuer (iss) claim. + * + * Like aud, the iss claim is optional for OpenID Connect Core and JAR (RFC 9101) Request Objects (RFC 9101 + * says a signed object SHOULD contain it). In JWT (RFC 7519) semantics iss identifies the party that + * issued the token, which for a Request Object is the client (the RP). So when a client includes it, iss + * MUST equal the client identifier; a mismatch means the object was not minted by this client. An absent + * iss is tolerated. + * + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + protected function verifyIssuer( + ConnectRequestObject|JarRequestObject $requestObject, + ClientEntityInterface $client, + string $redirectUri, + ?string $stateValue, + ResponseModeInterface $responseMode, + ): void { + $issuer = $requestObject->getIssuer(); + + // The claim is optional for these flavors; only validate it when present. + if ($issuer === null) { + return; + } + + if ($issuer !== $client->getIdentifier()) { + throw OidcServerException::invalidRequest( + 'request', + 'Request object issuer (iss) does not match the client.', + null, + $redirectUri, + $stateValue, + $responseMode, + ); + } + } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ diff --git a/src/Services/DatabaseMigration.php b/src/Services/DatabaseMigration.php index 3580dece..a282f109 100644 --- a/src/Services/DatabaseMigration.php +++ b/src/Services/DatabaseMigration.php @@ -753,9 +753,11 @@ private function version20260608130000(): void $fkParClient = $this->generateIdentifierName([$parTableName, 'client_id'], 'fk'); $idxParExpiresAt = $this->generateIdentifierName([$parTableName, 'expires_at'], 'idx'); + // request_uri is always a fixed-length value: REQUEST_URI_PREFIX (34 chars) + + // bin2hex(random_bytes(32)) (64 chars) = 98 chars. See PushedAuthorizationRequestEntityFactory::fromData(). $this->database->write(<<< EOT CREATE TABLE $parTableName ( - request_uri VARCHAR(191) PRIMARY KEY NOT NULL, + request_uri CHAR(98) PRIMARY KEY NOT NULL, client_id VARCHAR(191) NOT NULL, parameters TEXT NOT NULL, expires_at TIMESTAMP NOT NULL, diff --git a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php index 5cb50f58..3c16ede0 100644 --- a/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php +++ b/tests/unit/src/Server/RequestRules/Rules/RequestObjectRuleTest.php @@ -260,6 +260,117 @@ public function testThrowsWhenClientRequireSignedRequestObjectIsEnabled(): void ); } + public function testAcceptsOidcRequestWhenAudienceIncludesIssuer(): void + { + $this->prepareOidcRequest(); + $this->requestObjectMock->method('isProtected')->willReturn(false); + $this->requestObjectMock->method('getAudience')->willReturn(['https://op.example.org/']); + $this->moduleConfigStub->method('getIssuer')->willReturn('https://op.example.org/'); + + $result = $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + + $this->assertInstanceOf(Result::class, $result); + } + + public function testThrowsForOidcRequestWhenAudienceDoesNotIncludeIssuer(): void + { + $this->prepareOidcRequest(); + $this->requestObjectMock->method('isProtected')->willReturn(false); + $this->requestObjectMock->method('getAudience')->willReturn(['https://other-op.example.org/']); + $this->moduleConfigStub->method('getIssuer')->willReturn('https://op.example.org/'); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testThrowsForOAuth2RequestWhenAudienceDoesNotIncludeIssuer(): void + { + $this->jarRequestObjectMock->method('getClientId')->willReturn('client123'); + $this->jarRequestObjectMock->method('verifyWithKeySet')->with(['jwks']); + $this->jarRequestObjectMock->method('getAudience')->willReturn(['https://other-op.example.org/']); + $this->prepareOAuth2Request($this->jarRequestObjectMock); + + $this->jwksResolverMock->method('forClient')->with($this->clientStub)->willReturn(['jwks']); + $this->moduleConfigStub->method('getIssuer')->willReturn('https://op.example.org/'); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testAcceptsOidcRequestWhenIssuerMatchesClient(): void + { + $this->prepareOidcRequest(); + $this->requestObjectMock->method('isProtected')->willReturn(false); + $this->requestObjectMock->method('getIssuer')->willReturn('client123'); + + $result = $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + + $this->assertInstanceOf(Result::class, $result); + } + + public function testThrowsForOidcRequestWhenIssuerDoesNotMatchClient(): void + { + $this->prepareOidcRequest(); + $this->requestObjectMock->method('isProtected')->willReturn(false); + $this->requestObjectMock->method('getIssuer')->willReturn('otherClient'); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + + public function testThrowsForOAuth2RequestWhenIssuerDoesNotMatchClient(): void + { + $this->jarRequestObjectMock->method('getClientId')->willReturn('client123'); + $this->jarRequestObjectMock->method('verifyWithKeySet')->with(['jwks']); + $this->jarRequestObjectMock->method('getIssuer')->willReturn('otherClient'); + $this->prepareOAuth2Request($this->jarRequestObjectMock); + + $this->jwksResolverMock->method('forClient')->with($this->clientStub)->willReturn(['jwks']); + + $this->expectException(OidcServerException::class); + + $this->sut()->checkRule( + $this->requestStub, + $this->resultBagStub, + $this->loggerServiceStub, + [], + $this->responseModeStub, + ); + } + public function testThrowsForOAuth2RequestWithNonJarRequestObject(): void { // For example, an unsigned Request Object is not a valid JAR Request Object.