diff --git a/composer.json b/composer.json index 67753a7..e519a94 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "psr/simple-cache": "^3", "cicnavi/simple-file-cache-php": "^3", "guzzlehttp/guzzle": "^7.8", - "simplesamlphp/openid": "^0" + "simplesamlphp/openid": "^v0.3.2" }, "conflict": { "rector/rector": "2.3.0" diff --git a/docs/2-Pre-Registered-Client.md b/docs/2-Pre-Registered-Client.md index 21eff45..7d85506 100644 --- a/docs/2-Pre-Registered-Client.md +++ b/docs/2-Pre-Registered-Client.md @@ -33,6 +33,7 @@ use SimpleSAML\OpenID\Codebooks\PkceCodeChallengeMethodEnum; use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; use Cicnavi\Oidc\PreRegisteredClient; use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; +use Cicnavi\Oidc\CodeBooks\ParModeEnum; $oidcClient = new PreRegisteredClient( // Required parameters @@ -53,9 +54,32 @@ $oidcClient = new PreRegisteredClient( logger: null, // \Psr\Log\LoggerInterface instance defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::FormPost, // Determines the default authorization request method. responseMode: null, // Determines the OIDC response mode (e.g., ResponseModesEnum::Query or ResponseModesEnum::FormPost. Fragment is not supported). Null by default. + parMode: ParModeEnum::Auto, // Pushed Authorization Requests (RFC 9126) mode. See below. ); ``` +### Pushed Authorization Requests (PAR, RFC 9126) + +With PAR, the authorization request parameters are first POSTed directly to the +OP's `pushed_authorization_request_endpoint` (a back-channel, client-authenticated +call). The OP returns a short-lived, one-time `request_uri`, and the browser is +then sent to the authorization endpoint carrying only `client_id` and +`request_uri`. Client authentication uses the same `client_secret_basic` +credentials as the token endpoint, and PKCE / state / nonce are unchanged — only +the *delivery* of the request differs. PAR is orthogonal to +`AuthorizationRequestMethodEnum` (Query / FormPost). + +The `parMode` option (`ParModeEnum`) controls when PAR is used: + +- `ParModeEnum::Off` — never use PAR. Note: if the OP requires PAR + (`require_pushed_authorization_requests = true`), it will reject the request. +- `ParModeEnum::Auto` (default) — use PAR only when the OP requires it. Otherwise + the authorization request is delivered as usual. +- `ParModeEnum::Required` — always use PAR; an exception is thrown if the OP does + not advertise a `pushed_authorization_request_endpoint`. + +The mode can also be overridden per call: `$oidcClient->authorize(parMode: ParModeEnum::Required)`. + ## Client usage To initiate authorization (Authorization Code Flow), that is, to initiate a diff --git a/docs/3-Federated-Client.md b/docs/3-Federated-Client.md index 303953f..58a2113 100644 --- a/docs/3-Federated-Client.md +++ b/docs/3-Federated-Client.md @@ -71,13 +71,35 @@ $client = $factory->build(); // Direct instantiation with custom response mode: use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; +use Cicnavi\Oidc\CodeBooks\ParModeEnum; $client = new FederatedClient( entityConfig: $entityConfig, relyingPartyConfig: $relyingPartyConfig, - responseMode: ResponseModesEnum::FormPost // Optional + responseMode: ResponseModesEnum::FormPost, // Optional + parMode: ParModeEnum::Auto // Optional; Pushed Authorization Requests (RFC 9126) mode. See below. ); ``` +### Pushed Authorization Requests (PAR, RFC 9126) + +The Federated Client can deliver the authorization request via PAR: the +authorization parameters are POSTed directly to the OP's +`pushed_authorization_request_endpoint`, authenticated with `private_key_jwt`, +and the browser is then sent to the authorization endpoint carrying only +`client_id` and the returned one-time `request_uri`. In this PAR flow plain +authorization parameters are pushed (no signed Request Object is used). + +The `parMode` option (`ParModeEnum`) controls when PAR is used: + +- `ParModeEnum::Off` — never use PAR (the OP rejects the request if it requires PAR). +- `ParModeEnum::Auto` (default) — use PAR only when the OP advertises + `require_pushed_authorization_requests = true`. +- `ParModeEnum::Required` — always use PAR; throws if the OP advertises no + `pushed_authorization_request_endpoint`. + +The mode can also be overridden per call: +`$client->autoRegisterAndAuthenticate($opEntityId, parMode: ParModeEnum::Required)`. + ### 2. Initiating Authentication In your login controller, use the `autoRegisterAndAuthenticate` method. This diff --git a/src/CodeBooks/ParModeEnum.php b/src/CodeBooks/ParModeEnum.php new file mode 100644 index 0000000..5911631 --- /dev/null +++ b/src/CodeBooks/ParModeEnum.php @@ -0,0 +1,39 @@ +validateResponseMode($this->responseMode); $this->cache = $cache ?? new FileCache('ofacpc-' . md5($this->entityConfig->getEntityId())); @@ -301,6 +303,16 @@ public function getDefaultAuthorizationRequestMethod(): AuthorizationRequestMeth return $this->defaultAuthorizationRequestMethod; } + public function getResponseMode(): ?ResponseModesEnum + { + return $this->responseMode; + } + + public function getParMode(): ParModeEnum + { + return $this->parMode; + } + public function buildEntityStatement(): EntityStatement { $issuedAt = $this->federation->helpers()->dateTime()->getUtc(); @@ -520,9 +532,11 @@ public function autoRegisterAndAuthenticate( ?string $clientRedirectUri = null, ?AuthorizationRequestMethodEnum $authorizationRequestMethod = null, ?ResponseModesEnum $responseMode = null, + ?ParModeEnum $parMode = null, ): ?ResponseInterface { $authorizationRequestMethod ??= $this->defaultAuthorizationRequestMethod; $responseMode ??= $this->responseMode; + $parMode ??= $this->parMode; $this->validateResponseMode($responseMode); $trustAnchorBag = $this->entityConfig->getTrustAnchorBag(); if ($specificTrustAnchors instanceof TrustAnchorConfigBag) { @@ -633,6 +647,69 @@ public function autoRegisterAndAuthenticate( throw new OidcClientException('OpenID Provider authorization endpoint not available.'); } + $currentTimeUtc = $this->federation->helpers()->dateTime()->getUtc(); + $clientRedirectUri = $this->resolveClientRedirectUriForAuthorizationRequest($clientRedirectUri); + $state = $this->requestDataHandler->getState(); + $nonce = $this->useNonce ? $this->requestDataHandler->getNonce() : null; + $pkceCodeChallenge = $this->usePkce ? + $this->requestDataHandler->generateCodeChallengeFromCodeVerifier( + $this->requestDataHandler->getCodeVerifier(), + $this->pkceCodeChallengeMethod, + ) : + null; + $pkceCodeChallengeMethod = $this->usePkce ? $this->pkceCodeChallengeMethod->value : null; + $scope = $this->relyingPartyConfig->getScopeBag()->toString(); + + // Set resolved OP metadata for the state, so we can fetch it on callback. + $this->requestDataHandler->setResolvedOpMetadataForState($state, $opResolvedMetadata); + // Set used redirect URI for state, so we can fetch it on callback. + $this->requestDataHandler->setClientRedirectUriForState($state, $clientRedirectUri); + + $parEndpoint = $this->requestDataHandler->resolvePushedAuthorizationRequestEndpoint( + $opResolvedMetadata, + $parMode, + ); + + if (is_string($parEndpoint)) { + $this->logger?->debug('Delivering authorization request via PAR.', ['parEndpoint' => $parEndpoint]); + + // PAR pushes plain authorization parameters (no Request Object), + // authenticating the client with 'private_key_jwt'. + $parParameters = array_filter([ + ParamsEnum::ResponseType->value => ResponseTypesEnum::Code->value, + ParamsEnum::ClientId->value => $this->entityConfig->getEntityId(), + ParamsEnum::RedirectUri->value => $clientRedirectUri, + ParamsEnum::Scope->value => $scope, + ParamsEnum::ResponseMode->value => $responseMode?->value, + ParamsEnum::State->value => $state, + ParamsEnum::Nonce->value => $nonce, + ParamsEnum::CodeChallenge->value => $pkceCodeChallenge, + ParamsEnum::CodeChallengeMethod->value => $pkceCodeChallengeMethod, + ParamsEnum::LoginHint->value => $loginHint, + ]); + + $parResponse = $this->requestDataHandler->pushAuthorizationRequest( + clientAuthenticationMethod: ClientAuthenticationMethodsEnum::PrivateKeyJwt, + pushedAuthorizationRequestEndpoint: $parEndpoint, + parameters: $parParameters, + clientId: $this->entityConfig->getEntityId(), + clientAssertion: $this->buildClientAssertion($opResolvedMetadata, $openIdProviderEntityId), + ); + + // The front-channel request now carries only client_id + request_uri. + $authorizationParameters = [ + ParamsEnum::ClientId->value => $this->entityConfig->getEntityId(), + ParamsEnum::RequestUri->value => $parResponse[ParamsEnum::RequestUri->value], + ]; + + return $this->dispatchAuthorizationRequest( + $opAuthorizationEndpoint, + $authorizationParameters, + $authorizationRequestMethod, + $response, + ); + } + $signingKeyPair = $this->federation->keyPairResolver()->resolveSignatureKeyPairByAlgorithm( signatureKeyPairBag: $this->connectSignatureKeyPairBag, receiverEntityMetadata: $opResolvedMetadata, @@ -666,24 +743,6 @@ public function autoRegisterAndAuthenticate( $rpTrustChain->jsonSerialize(), ); - $currentTimeUtc = $this->federation->helpers()->dateTime()->getUtc(); - $clientRedirectUri = $this->resolveClientRedirectUriForAuthorizationRequest($clientRedirectUri); - $state = $this->requestDataHandler->getState(); - $nonce = $this->useNonce ? $this->requestDataHandler->getNonce() : null; - $pkceCodeChallenge = $this->usePkce ? - $this->requestDataHandler->generateCodeChallengeFromCodeVerifier( - $this->requestDataHandler->getCodeVerifier(), - $this->pkceCodeChallengeMethod, - ) : - null; - $pkceCodeChallengeMethod = $this->usePkce ? $this->pkceCodeChallengeMethod->value : null; - $scope = $this->relyingPartyConfig->getScopeBag()->toString(); - - // Set resolved OP metadata for the state, so we can fetch it on callback. - $this->requestDataHandler->setResolvedOpMetadataForState($state, $opResolvedMetadata); - // Set used redirect URI for state, so we can fetch it on callback. - $this->requestDataHandler->setClientRedirectUriForState($state, $clientRedirectUri); - $requestObjectPayload = array_filter([ ClaimsEnum::Aud->value => $openIdProviderEntityId, ClaimsEnum::ClientId->value => $this->entityConfig->getEntityId(), @@ -743,6 +802,26 @@ public function autoRegisterAndAuthenticate( $authorizationParameters, ); + return $this->dispatchAuthorizationRequest( + $opAuthorizationEndpoint, + $authorizationParameters, + $authorizationRequestMethod, + $response, + ); + } + + /** + * Deliver the front-channel authorization request to the OP, either as an + * auto-submitting POST form or as a redirect, depending on the method. + * + * @param array $authorizationParameters + */ + protected function dispatchAuthorizationRequest( + string $opAuthorizationEndpoint, + array $authorizationParameters, + AuthorizationRequestMethodEnum $authorizationRequestMethod, + ?ResponseInterface $response, + ): ?ResponseInterface { if ($authorizationRequestMethod === AuthorizationRequestMethodEnum::FormPost) { $formHtml = HttpHelper::generateAutoSubmitPostForm($opAuthorizationEndpoint, $authorizationParameters); if ($response instanceof ResponseInterface) { @@ -829,9 +908,38 @@ public function getUserData(?ServerRequestInterface $request = null): array throw new OidcClientException('OpenID Provider entity ID not available.'); } + return $this->requestDataHandler->getUserData( + clientAuthenticationMethod: ClientAuthenticationMethodsEnum::PrivateKeyJwt, + authorizationCode: $authorizationCode, + clientId: $this->entityConfig->getEntityId(), + clientRedirectUri: $clientRedirectUri, + opJwksUri: $opJwksUri, + opTokenEndpoint: $opTokenEndpoint, + opUserinfoEndpoint: $opUserinfoEndpoint, + clientSecret: null, + clientAssertion: $this->buildClientAssertion($resolvedOpMetadata, $opEntityId), + usePkce: $this->usePkce, + useNonce: $this->useNonce, + fetchUserinfoClaims: $this->fetchUserinfoClaims, + expectedIssuer: $opEntityId, + ); + } + + /** + * Build a signed 'private_key_jwt' client assertion for back-channel client + * authentication (token endpoint, PAR endpoint...). The audience is the + * OP's issuer identifier, which the OP also accepts at its token / PAR + * endpoints per RFC 9126 §2. + * + * @param array $opResolvedMetadata Resolved OP metadata. + * @param string $opEntityId OP issuer identifier, used as the assertion audience. + * @return string The serialized client assertion JWT. + */ + protected function buildClientAssertion(array $opResolvedMetadata, string $opEntityId): string + { $signingKeyPair = $this->federation->keyPairResolver()->resolveSignatureKeyPairByAlgorithm( signatureKeyPairBag: $this->connectSignatureKeyPairBag, - receiverEntityMetadata: $resolvedOpMetadata, + receiverEntityMetadata: $opResolvedMetadata, receiverSupportedSignatureAlgorithmsMetadataKey: ClaimsEnum::TokenEndpointAuthSigningAlgValuesSupported->value, ); @@ -851,28 +959,12 @@ public function getUserData(?ServerRequestInterface $request = null): array ClaimsEnum::Kid->value => $signingKeyPair->getKeyPair()->getKeyId(), ]; - $clientAssertion = $this->core->clientAssertionFactory()->fromData( + return $this->core->clientAssertionFactory()->fromData( $signingKeyPair->getKeyPair()->getPrivateKey(), $signingKeyPair->getSignatureAlgorithm(), $clientAssertionPayload, $clientAssertionHeader, - ); - - return $this->requestDataHandler->getUserData( - clientAuthenticationMethod: ClientAuthenticationMethodsEnum::PrivateKeyJwt, - authorizationCode: $authorizationCode, - clientId: $this->entityConfig->getEntityId(), - clientRedirectUri: $clientRedirectUri, - opJwksUri: $opJwksUri, - opTokenEndpoint: $opTokenEndpoint, - opUserinfoEndpoint: $opUserinfoEndpoint, - clientSecret: null, - clientAssertion: $clientAssertion->getToken(), - usePkce: $this->usePkce, - useNonce: $this->useNonce, - fetchUserinfoClaims: $this->fetchUserinfoClaims, - expectedIssuer: $opEntityId, - ); + )->getToken(); } /** diff --git a/src/PreRegisteredClient.php b/src/PreRegisteredClient.php index 4c6b4ff..d277c18 100644 --- a/src/PreRegisteredClient.php +++ b/src/PreRegisteredClient.php @@ -6,6 +6,7 @@ use Cicnavi\Oidc\Cache\FileCache; use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; +use Cicnavi\Oidc\CodeBooks\ParModeEnum; use Cicnavi\Oidc\DataStore\Interfaces\SessionStoreInterface; use Cicnavi\Oidc\DataStore\PhpSessionStore; use Cicnavi\Oidc\Exceptions\OidcClientException; @@ -130,6 +131,7 @@ public function __construct( protected readonly AuthorizationRequestMethodEnum $defaultAuthorizationRequestMethod = AuthorizationRequestMethodEnum::FormPost, protected readonly ?ResponseModesEnum $responseMode = null, ?RequestDataHandler $requestDataHandler = null, + protected readonly ParModeEnum $parMode = ParModeEnum::Auto, ) { $this->validateResponseMode($this->responseMode); @@ -200,9 +202,11 @@ public function authorize( ?AuthorizationRequestMethodEnum $authorizationRequestMethod = null, ?ResponseInterface $response = null, ?ResponseModesEnum $responseMode = null, + ?ParModeEnum $parMode = null, ): ?ResponseInterface { $authorizationRequestMethod ??= $this->defaultAuthorizationRequestMethod; $responseMode ??= $this->responseMode; + $parMode ??= $this->parMode; $this->validateResponseMode($responseMode); @@ -236,6 +240,29 @@ public function authorize( throw new OidcClientException('Authorization endpoint not found in OP metadata.'); } + $parEndpoint = $this->requestDataHandler->resolvePushedAuthorizationRequestEndpoint( + $this->collectParRelatedOpMetadata(), + $parMode, + ); + + if (is_string($parEndpoint)) { + $this->logger?->debug('Delivering authorization request via PAR.', ['parEndpoint' => $parEndpoint]); + + $parResponse = $this->requestDataHandler->pushAuthorizationRequest( + clientAuthenticationMethod: ClientAuthenticationMethodsEnum::ClientSecretBasic, + pushedAuthorizationRequestEndpoint: $parEndpoint, + parameters: $parameters, + clientId: $this->clientId, + clientSecret: $this->clientSecret, + ); + + // The front-channel request now carries only client_id + request_uri. + $parameters = [ + ParamsEnum::ClientId->value => $this->clientId, + ParamsEnum::RequestUri->value => $parResponse[ParamsEnum::RequestUri->value], + ]; + } + if ($authorizationRequestMethod === AuthorizationRequestMethodEnum::FormPost) { $formHtml = HttpHelper::generateAutoSubmitPostForm($authorizationEndpoint, $parameters); if ($response instanceof ResponseInterface) { @@ -318,6 +345,37 @@ public function getMetadata(): MetadataInterface return $this->metadata; } + public function getParMode(): ParModeEnum + { + return $this->parMode; + } + + /** + * Read the PAR-related OP metadata values used to decide whether to use PAR. + * Missing values are simply omitted (the OP does not support / require PAR). + * + * @return array + */ + protected function collectParRelatedOpMetadata(): array + { + $parRelatedOpMetadata = []; + + foreach ( + [ + ClaimsEnum::PushedAuthorizationRequestEndpoint->value, + ClaimsEnum::RequirePushedAuthorizationRequests->value, + ] as $key + ) { + try { + $parRelatedOpMetadata[$key] = $this->metadata->get($key); + } catch (OidcClientException) { + // Not advertised by this OP; leave it out. + } + } + + return $parRelatedOpMetadata; + } + /** * @throws CacheException */ diff --git a/src/Protocol/RequestDataHandler.php b/src/Protocol/RequestDataHandler.php index b0dc2b1..e6d2072 100644 --- a/src/Protocol/RequestDataHandler.php +++ b/src/Protocol/RequestDataHandler.php @@ -5,6 +5,7 @@ namespace Cicnavi\Oidc\Protocol; use Cicnavi\Oidc\Bridges\GuzzleBridge; +use Cicnavi\Oidc\CodeBooks\ParModeEnum; use Cicnavi\Oidc\DataStore\DataHandlers\Interfaces\PkceDataHandlerInterface; use Cicnavi\Oidc\DataStore\DataHandlers\Interfaces\StateNonceDataHandlerInterface; use Cicnavi\Oidc\DataStore\DataHandlers\Pkce; @@ -232,6 +233,62 @@ public function requestTokenData( 'Content-Type' => 'application/x-www-form-urlencoded', ]; + $clientAuthentication = $this->buildClientAuthentication( + $clientAuthenticationMethod, + $clientId, + $clientSecret, + $clientAssertion, + ); + $params = array_merge($params, $clientAuthentication['params']); + $headers = array_merge($headers, $clientAuthentication['headers']); + + if ($usePkce) { + $params[ParamsEnum::CodeVerifier->value] = $this->pkceDataHandler->getCodeVerifier(); + } + + try { + $bodyStream = $this->guzzleBridge->psr7StreamFor(http_build_query($params)); + + $tokenRequest = $this->httpRequestFactory + ->createRequest(HttpMethodsEnum::POST->value, $tokenEndpoint) + ->withBody($bodyStream); + + foreach ($headers as $key => $value) { + $tokenRequest = $tokenRequest->withHeader($key, $value); + } + + $response = $this->httpClient->sendRequest($tokenRequest); + + $this->validateHttpResponseOk($response); + + return $this->getDecodedHttpResponseJson($response); + } catch (Throwable $throwable) { + throw new OidcClientException( + 'Token data request error. ' . $throwable->getMessage(), + $throwable->getCode(), + $throwable, + ); + } + } + + /** + * Build client authentication params/headers for a back-channel request + * (token endpoint, PAR endpoint...). Supports the same methods as the token + * endpoint: 'client_secret_basic' (Authorization header) and + * 'private_key_jwt' (client_assertion params). + * + * @return array{params: array, headers: array} + * @throws OidcClientException + */ + protected function buildClientAuthentication( + ClientAuthenticationMethodsEnum $clientAuthenticationMethod, + string $clientId, + ?string $clientSecret, + ?string $clientAssertion, + ): array { + $params = []; + $headers = []; + if ($clientAuthenticationMethod === ClientAuthenticationMethodsEnum::ClientSecretBasic) { if (!is_string($clientSecret)) { throw new OidcClientException( @@ -253,35 +310,196 @@ public function requestTokenData( $params[ParamsEnum::ClientAssertion->value] = $clientAssertion; } - if ($usePkce) { - $params[ParamsEnum::CodeVerifier->value] = $this->pkceDataHandler->getCodeVerifier(); + return ['params' => $params, 'headers' => $headers]; + } + + /** + * Decide whether Pushed Authorization Requests (PAR, RFC 9126) should be + * used for the given OP and, if so, return the PAR endpoint to push to. + * + * @param array $opMetadata Resolved OP metadata. The keys of + * interest are 'pushed_authorization_request_endpoint' and + * 'require_pushed_authorization_requests'. + * @return ?non-empty-string The PAR endpoint to push to, or null when PAR + * should not be used for this OP under the given mode. + * @throws OidcClientException When PAR must be used but the OP does not + * advertise a 'pushed_authorization_request_endpoint'. + */ + public function resolvePushedAuthorizationRequestEndpoint( + array $opMetadata, + ParModeEnum $parMode, + ): ?string { + if ($parMode === ParModeEnum::Off) { + return null; + } + + $endpoint = $opMetadata[ClaimsEnum::PushedAuthorizationRequestEndpoint->value] ?? null; + $endpoint = (is_string($endpoint) && $endpoint !== '') ? $endpoint : null; + + $opRequiresPar = + ($opMetadata[ClaimsEnum::RequirePushedAuthorizationRequests->value] ?? false) === true; + + // In 'auto' mode PAR is used only when the OP requires it. + if ($parMode === ParModeEnum::Auto && !$opRequiresPar) { + return null; + } + + // From here PAR must be used (mode 'required', or 'auto' and the OP + // requires it). A PAR endpoint is therefore mandatory. + if ($endpoint === null) { + throw new OidcClientException( + 'Pushed Authorization Requests must be used for this OpenID Provider, but it does not ' . + 'advertise a "pushed_authorization_request_endpoint".', + ); + } + + return $endpoint; + } + + /** + * Push an authorization request (RFC 9126) to the OP's PAR endpoint and + * return the resulting one-time 'request_uri'. + * + * The request is a back-channel POST (application/x-www-form-urlencoded) + * carrying every authorization-request parameter the RP would otherwise put + * in the front-channel redirect, plus client authentication (the same + * methods as the token endpoint). The 'request_uri' parameter MUST NOT be + * part of the pushed parameters. + * + * @param array $parameters Authorization-request parameters + * to push (response_type, redirect_uri, scope, state, nonce, + * code_challenge...). 'client_id' is set from $clientId; any 'request_uri' + * is rejected. + * @return array{request_uri: non-empty-string, expires_in: int} + * @throws OidcClientException + */ + public function pushAuthorizationRequest( + ClientAuthenticationMethodsEnum $clientAuthenticationMethod, + string $pushedAuthorizationRequestEndpoint, + array $parameters, + string $clientId, + ?string $clientSecret = null, // For client_secret_basic client authentication + ?string $clientAssertion = null, // For private_key_jwt client authentication + ): array { + if (array_key_exists(ParamsEnum::RequestUri->value, $parameters)) { + throw new OidcClientException( + 'The "request_uri" parameter must not be sent in a pushed authorization request.', + ); } + // client_id is a required authorization-request parameter in the PAR body. + $parameters[ParamsEnum::ClientId->value] = $clientId; + + $headers = [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/x-www-form-urlencoded', + ]; + + $clientAuthentication = $this->buildClientAuthentication( + $clientAuthenticationMethod, + $clientId, + $clientSecret, + $clientAssertion, + ); + $parameters = array_merge($parameters, $clientAuthentication['params']); + $headers = array_merge($headers, $clientAuthentication['headers']); + try { - $bodyStream = $this->guzzleBridge->psr7StreamFor(http_build_query($params)); + $bodyStream = $this->guzzleBridge->psr7StreamFor(http_build_query($parameters)); - $tokenRequest = $this->httpRequestFactory - ->createRequest(HttpMethodsEnum::POST->value, $tokenEndpoint) + $parRequest = $this->httpRequestFactory + ->createRequest(HttpMethodsEnum::POST->value, $pushedAuthorizationRequestEndpoint) ->withBody($bodyStream); foreach ($headers as $key => $value) { - $tokenRequest = $tokenRequest->withHeader($key, $value); + $parRequest = $parRequest->withHeader($key, $value); } - $response = $this->httpClient->sendRequest($tokenRequest); + $response = $this->httpClient->sendRequest($parRequest); - $this->validateHttpResponseOk($response); + $this->validatePushedAuthorizationResponse($response); - return $this->getDecodedHttpResponseJson($response); + return $this->validatePushedAuthorizationResponseData( + $this->getDecodedHttpResponseJson($response), + ); + } catch (OidcClientException $oidcClientException) { + // Already a meaningful error (rejection, invalid response...); surface as-is. + throw $oidcClientException; } catch (Throwable $throwable) { throw new OidcClientException( - 'Token data request error. ' . $throwable->getMessage(), + 'Pushed authorization request error. ' . $throwable->getMessage(), $throwable->getCode(), $throwable, ); } } + /** + * Ensure that the PAR endpoint response indicates success (HTTP 201). + * Errors are returned in the token-endpoint error format (JSON), never as a + * redirect, so surface 'error' / 'error_description' when present. + * + * @throws OidcClientException If the response does not indicate success. + */ + public function validatePushedAuthorizationResponse(ResponseInterface $response): void + { + $httpStatusCode = $response->getStatusCode(); + if ($httpStatusCode === 201) { + return; + } + + $message = sprintf( + 'Pushed authorization request was not successful (HTTP %s - %s).', + $httpStatusCode, + $response->getReasonPhrase(), + ); + + try { + $errorData = $this->decodeJsonOrThrow((string) $response->getBody()); + $error = $errorData[ParamsEnum::Error->value] ?? null; + $errorDescription = $errorData[ParamsEnum::ErrorDescription->value] ?? null; + if (is_string($error)) { + $message = sprintf( + 'Pushed authorization request rejected by OpenID Provider - error "%s"%s', + $error, + is_string($errorDescription) ? ': ' . $errorDescription . '.' : '.', + ); + } + } catch (Throwable) { + // Body was not a JSON error object; keep the generic status message. + } + + $this->logger?->error($message); + throw new OidcClientException($message); + } + + /** + * Validate the decoded PAR success response body. + * + * @param mixed[] $data Decoded JSON from the PAR endpoint response. + * @return array{request_uri: non-empty-string, expires_in: int} + * @throws OidcClientException + */ + public function validatePushedAuthorizationResponseData(array $data): array + { + $requestUri = $data[ParamsEnum::RequestUri->value] ?? null; + if (!is_string($requestUri) || $requestUri === '') { + throw new OidcClientException( + 'Pushed authorization response does not contain a valid "request_uri".', + ); + } + + // expires_in is informational for the RP (the redirect is issued + // immediately), so treat it as best-effort. + $expiresIn = $data[ParamsEnum::ExpiresIn->value] ?? null; + $expiresIn = is_numeric($expiresIn) ? (int) $expiresIn : 0; + + return [ + ParamsEnum::RequestUri->value => $requestUri, + ParamsEnum::ExpiresIn->value => $expiresIn, + ]; + } + /** * Ensure that HTTP response is 200 OK * diff --git a/tests/Oidc/FederatedClientTest.php b/tests/Oidc/FederatedClientTest.php index 65ff7d7..279aac7 100644 --- a/tests/Oidc/FederatedClientTest.php +++ b/tests/Oidc/FederatedClientTest.php @@ -5,7 +5,9 @@ namespace Cicnavi\Tests\Oidc; use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; +use Cicnavi\Oidc\CodeBooks\ParModeEnum; use Cicnavi\Oidc\DataStore\Interfaces\SessionStoreInterface; +use SimpleSAML\OpenID\Codebooks\ClientAuthenticationMethodsEnum; use Cicnavi\Oidc\Exceptions\OidcClientException; use Cicnavi\Oidc\FederatedClient; use Cicnavi\Oidc\Helpers\HttpHelper; @@ -905,4 +907,119 @@ public function testAutoRegisterAndAuthenticateSuccessRedirectWithResponseMode() ); $this->assertSame($responseMock, $result); } + + public function testGetParModeDefaultsToAuto(): void + { + $this->assertSame(ParModeEnum::Auto, $this->sut()->getParMode()); + } + + public function testAutoRegisterAndAuthenticateUsesParWithPlainParameters(): void + { + $opEntityId = 'https://op.example.org'; + $trustAnchorId = 'https://ta.example.org'; + + $trustAnchorBagMock = $this->createMock(TrustAnchorConfigBag::class); + $trustAnchorBagMock->method('getAllEntityIds')->willReturn([$trustAnchorId]); + $this->entityConfigMock->method('getTrustAnchorBag')->willReturn($trustAnchorBagMock); + $this->entityConfigMock->method('getEntityId')->willReturn('https://rp.example.org'); + + $opTrustChainBagMock = $this->createMock(TrustChainBag::class); + $trustChainResolverMock = $this->createMock(TrustChainResolver::class); + $this->federationMock->method('trustChainResolver')->willReturn($trustChainResolverMock); + // Only the OP trust chain is resolved; the RP trust chain is not needed for PAR plain params. + $trustChainResolverMock->method('for')->willReturn($opTrustChainBagMock); + + $opTrustChainMock = $this->createMock(TrustChain::class); + $opTrustChainBagMock->method('getShortest')->willReturn($opTrustChainMock); + + $opEntityStatementMock = $this->createMock(EntityStatement::class); + $opTrustChainMock->method('getResolvedLeaf')->willReturn($opEntityStatementMock); + $opEntityStatementMock->method('getSubject')->willReturn($opEntityId); + $opEntityStatementMock->method('getIssuer')->willReturn($opEntityId); + + $opMetadata = [ + 'authorization_endpoint' => 'https://op.example.org/auth', + 'pushed_authorization_request_endpoint' => 'https://op.example.org/par', + 'require_pushed_authorization_requests' => true, + 'issuer' => $opEntityId, + ]; + $opTrustChainMock->method('getResolvedMetadata')->willReturn($opMetadata); + + // buildClientAssertion dependencies. + $keyPairResolverMock = $this->createMock(\SimpleSAML\OpenID\Utils\KeyPairResolver::class); + $this->federationMock->method('keyPairResolver')->willReturn($keyPairResolverMock); + $signingKeyPairMock = $this->createMock(SignatureKeyPair::class); + $keyPairResolverMock->method('resolveSignatureKeyPairByAlgorithm')->willReturn($signingKeyPairMock); + $innerKeyPairMock = $this->createMock(\SimpleSAML\OpenID\ValueAbstracts\KeyPair::class); + $signingKeyPairMock->method('getKeyPair')->willReturn($innerKeyPairMock); + $innerKeyPairMock->method('getKeyId')->willReturn('kid1'); + $innerKeyPairMock->method('getPrivateKey')->willReturn($this->createStub(JwkDecorator::class)); + $signingKeyPairMock->method('getSignatureAlgorithm')->willReturn(SignatureAlgorithmEnum::ES256); + + $clientAssertionMock = $this->createMock(ClientAssertion::class); + $clientAssertionFactoryMock = $this->createMock(ClientAssertionFactory::class); + $this->coreMock->method('clientAssertionFactory')->willReturn($clientAssertionFactoryMock); + $clientAssertionFactoryMock->method('fromData')->willReturn($clientAssertionMock); + $clientAssertionMock->method('getToken')->willReturn('client-assertion-token'); + + $helpersMock = $this->createMock(\SimpleSAML\OpenID\Helpers::class); + $this->federationMock->method('helpers')->willReturn($helpersMock); + $dateTimeHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\DateTime::class); + $helpersMock->method('dateTime')->willReturn($dateTimeHelperMock); + $dateTimeHelperMock->method('getUtc')->willReturn(new \DateTimeImmutable()); + $randomHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\Random::class); + $helpersMock->method('random')->willReturn($randomHelperMock); + $randomHelperMock->method('string')->willReturn('random_jti'); + + $redirectUriBagMock = $this->createMock(RedirectUriBag::class); + $redirectUriBagMock->method('getDefaultRedirectUri')->willReturn('https://rp.example.org/callback'); + $this->realyingPartyConfigMock->method('getRedirectUriBag')->willReturn($redirectUriBagMock); + $this->realyingPartyConfigMock->method('getScopeBag')->willReturn(new ScopeBag('openid')); + + $this->requestDataHandlerMock->method('getState')->willReturn('state123'); + $this->requestDataHandlerMock->method('getNonce')->willReturn('nonce123'); + + $this->requestDataHandlerMock->method('resolvePushedAuthorizationRequestEndpoint') + ->willReturn('https://op.example.org/par'); + + // PAR must push plain params with private_key_jwt client authentication. + $this->requestDataHandlerMock->expects($this->once()) + ->method('pushAuthorizationRequest') + ->with( + ClientAuthenticationMethodsEnum::PrivateKeyJwt, + 'https://op.example.org/par', + $this->callback(fn(array $parameters): bool => + ($parameters['response_type'] ?? null) === 'code' && + ($parameters['client_id'] ?? null) === 'https://rp.example.org' && + ($parameters['redirect_uri'] ?? null) === 'https://rp.example.org/callback' && + ($parameters['scope'] ?? null) === 'openid' && + !array_key_exists('request', $parameters)), + 'https://rp.example.org', + null, + 'client-assertion-token', + ) + ->willReturn(['request_uri' => 'urn:par:xyz', 'expires_in' => 60]); + + // The request object factory must NOT be used in the PAR plain-params path. + $requestObjectFactoryMock = $this->createMock(RequestObjectFactory::class); + $this->federationMock->method('requestObjectFactory')->willReturn($requestObjectFactoryMock); + $requestObjectFactoryMock->expects($this->never())->method('fromData'); + + $body = $this->createMock(StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->callback(fn(string $html): bool => + str_contains($html, 'name="client_id"') && + str_contains($html, 'name="request_uri"') && + str_contains($html, 'value="urn:par:xyz"') && + !str_contains($html, 'name="request"') && + !str_contains($html, 'name="scope"'))); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->method('getBody')->willReturn($body); + $responseMock->method('withHeader')->with('Content-Type', 'text/html')->willReturn($responseMock); + + $result = $this->sut()->autoRegisterAndAuthenticate($opEntityId, response: $responseMock); + $this->assertSame($responseMock, $result); + } } diff --git a/tests/Oidc/PreRegisteredClientTest.php b/tests/Oidc/PreRegisteredClientTest.php index d6efd42..7aef423 100644 --- a/tests/Oidc/PreRegisteredClientTest.php +++ b/tests/Oidc/PreRegisteredClientTest.php @@ -5,7 +5,9 @@ namespace Cicnavi\Tests\Oidc; use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; +use Cicnavi\Oidc\CodeBooks\ParModeEnum; use Cicnavi\Oidc\DataStore\Interfaces\SessionStoreInterface; +use SimpleSAML\OpenID\Codebooks\ClientAuthenticationMethodsEnum; use Cicnavi\Oidc\Interfaces\MetadataInterface; use Cicnavi\Oidc\PreRegisteredClient; use Cicnavi\Oidc\Protocol\RequestDataHandler; @@ -202,8 +204,10 @@ public function testGetMetadata(): void public function testAuthorizeFormPostWithResponse(): void { - $this->metadataMock->expects($this->exactly(1))->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ ['authorization_endpoint', 'https://auth.example.org/authorize'], + ['pushed_authorization_request_endpoint', null], + ['require_pushed_authorization_requests', null], ]); $this->requestDataHandlerMock->expects($this->once())->method('getState')->willReturn('state-123'); @@ -237,8 +241,10 @@ public function testAuthorizeFormPostWithResponse(): void public function testAuthorizeRedirectGetWithResponse(): void { - $this->metadataMock->expects($this->exactly(1))->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ ['authorization_endpoint', 'https://auth.example.org/authorize'], + ['pushed_authorization_request_endpoint', null], + ['require_pushed_authorization_requests', null], ]); $this->requestDataHandlerMock->method('getState')->willReturn('state-abc'); @@ -345,8 +351,10 @@ public function testAuthorizeThrowsIfResponseModeIsFragment(): void public function testAuthorizeQueryWithResponseMode(): void { - $this->metadataMock->expects($this->once())->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ ['authorization_endpoint', 'https://auth.example.org/authorize'], + ['pushed_authorization_request_endpoint', null], + ['require_pushed_authorization_requests', null], ]); $this->requestDataHandlerMock->method('getState')->willReturn('state-abc'); @@ -374,10 +382,103 @@ public function testAuthorizeQueryWithResponseMode(): void $this->assertSame($response, $result); } + public function testGetParModeDefaultsToAuto(): void + { + $this->assertSame(ParModeEnum::Auto, $this->sut()->getParMode()); + } + + public function testAuthorizeUsesParAndReducesFrontChannelRequest(): void + { + $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ + ['authorization_endpoint', 'https://auth.example.org/authorize'], + ['pushed_authorization_request_endpoint', 'https://auth.example.org/par'], + ['require_pushed_authorization_requests', true], + ]); + + $this->requestDataHandlerMock->method('getState')->willReturn('state-123'); + $this->requestDataHandlerMock->method('getNonce')->willReturn('nonce-123'); + $this->requestDataHandlerMock->method('getCodeVerifier')->willReturn('code-verifier'); + $this->requestDataHandlerMock->method('generateCodeChallengeFromCodeVerifier') + ->willReturn('code-challenge'); + + $this->requestDataHandlerMock->method('resolvePushedAuthorizationRequestEndpoint') + ->willReturn('https://auth.example.org/par'); + + // The full authorization parameter set must be pushed (with client auth). + $this->requestDataHandlerMock->expects($this->once()) + ->method('pushAuthorizationRequest') + ->with( + ClientAuthenticationMethodsEnum::ClientSecretBasic, + 'https://auth.example.org/par', + $this->callback(function (array $parameters): bool { + $this->assertSame('code', $parameters['response_type'] ?? null); + $this->assertSame($this->clientId, $parameters['client_id'] ?? null); + $this->assertSame($this->scope, $parameters['scope'] ?? null); + $this->assertSame('code-challenge', $parameters['code_challenge'] ?? null); + return true; + }), + $this->clientId, + $this->clientSecret, + ) + ->willReturn(['request_uri' => 'urn:par:abc', 'expires_in' => 60]); + + $body = $this->createMock(\Psr\Http\Message\StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->callback(fn(string $html): bool => + // Only client_id + request_uri are delivered in the front channel. + str_contains($html, 'name="client_id"') && + str_contains($html, 'name="request_uri"') && + str_contains($html, 'value="urn:par:abc"') && + !str_contains($html, 'name="response_type"') && + !str_contains($html, 'name="scope"'))); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->method('getBody')->willReturn($body); + $response->method('withHeader')->with('Content-Type', 'text/html')->willReturn($response); + + $result = $this->sut()->authorize(AuthorizationRequestMethodEnum::FormPost, $response); + $this->assertSame($response, $result); + } + + public function testAuthorizeDoesNotUseParWhenResolverReturnsNull(): void + { + $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ + ['authorization_endpoint', 'https://auth.example.org/authorize'], + ['pushed_authorization_request_endpoint', null], + ['require_pushed_authorization_requests', null], + ]); + + $this->requestDataHandlerMock->method('getState')->willReturn('state-abc'); + $this->requestDataHandlerMock->method('getNonce')->willReturn('nonce-abc'); + $this->requestDataHandlerMock->method('getCodeVerifier')->willReturn('code-verifier'); + $this->requestDataHandlerMock->method('generateCodeChallengeFromCodeVerifier') + ->willReturn('code-challenge'); + + $this->requestDataHandlerMock->method('resolvePushedAuthorizationRequestEndpoint')->willReturn(null); + $this->requestDataHandlerMock->expects($this->never())->method('pushAuthorizationRequest'); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->expects($this->once()) + ->method('withHeader') + ->with( + 'Location', + $this->callback(fn(string $location): bool => + str_contains($location, 'response_type=code') && + !str_contains($location, 'request_uri')) + ) + ->willReturn($response); + + $result = $this->sut()->authorize(AuthorizationRequestMethodEnum::Query, $response); + $this->assertSame($response, $result); + } + public function testAuthorizeFormPostWithResponseMode(): void { - $this->metadataMock->expects($this->once())->method('get')->willReturnMap([ + $this->metadataMock->expects($this->exactly(3))->method('get')->willReturnMap([ ['authorization_endpoint', 'https://auth.example.org/authorize'], + ['pushed_authorization_request_endpoint', null], + ['require_pushed_authorization_requests', null], ]); $this->requestDataHandlerMock->method('getState')->willReturn('state-123'); diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index 6ecd853..ab00d83 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -5,6 +5,7 @@ namespace Cicnavi\Tests\Oidc\Protocol; use Cicnavi\Oidc\Bridges\GuzzleBridge; +use Cicnavi\Oidc\CodeBooks\ParModeEnum; use Cicnavi\Oidc\DataStore\DataHandlers\Interfaces\PkceDataHandlerInterface; use Cicnavi\Oidc\DataStore\DataHandlers\Interfaces\StateNonceDataHandlerInterface; use Cicnavi\Oidc\DataStore\DataHandlers\StateNonce; @@ -1129,4 +1130,228 @@ public function testGetDecodedHttpResponseJsonThrowsOnNonArrayJson(): void $this->sut()->getDecodedHttpResponseJson($response); } + + public function testResolveParEndpointReturnsNullWhenModeOff(): void + { + $this->assertNull( + $this->sut()->resolvePushedAuthorizationRequestEndpoint( + ['pushed_authorization_request_endpoint' => 'https://op.example.com/par'], + ParModeEnum::Off, + ), + ); + } + + public function testResolveParEndpointAutoReturnsNullWhenOpDoesNotRequirePar(): void + { + // OP advertises the endpoint but does not require PAR: 'auto' does not use it. + $this->assertNull( + $this->sut()->resolvePushedAuthorizationRequestEndpoint( + ['pushed_authorization_request_endpoint' => 'https://op.example.com/par'], + ParModeEnum::Auto, + ), + ); + } + + public function testResolveParEndpointAutoReturnsEndpointWhenOpRequiresPar(): void + { + $this->assertSame( + 'https://op.example.com/par', + $this->sut()->resolvePushedAuthorizationRequestEndpoint( + [ + 'pushed_authorization_request_endpoint' => 'https://op.example.com/par', + 'require_pushed_authorization_requests' => true, + ], + ParModeEnum::Auto, + ), + ); + } + + public function testResolveParEndpointAutoThrowsWhenRequiredButEndpointMissing(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('does not advertise a "pushed_authorization_request_endpoint"'); + + $this->sut()->resolvePushedAuthorizationRequestEndpoint( + ['require_pushed_authorization_requests' => true], + ParModeEnum::Auto, + ); + } + + public function testResolveParEndpointRequiredReturnsEndpoint(): void + { + $this->assertSame( + 'https://op.example.com/par', + $this->sut()->resolvePushedAuthorizationRequestEndpoint( + ['pushed_authorization_request_endpoint' => 'https://op.example.com/par'], + ParModeEnum::Required, + ), + ); + } + + public function testResolveParEndpointRequiredThrowsWhenEndpointMissing(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('does not advertise a "pushed_authorization_request_endpoint"'); + + $this->sut()->resolvePushedAuthorizationRequestEndpoint([], ParModeEnum::Required); + } + + public function testPushAuthorizationRequestRejectsRequestUriInParameters(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('"request_uri" parameter must not be sent'); + + $this->sut()->pushAuthorizationRequest( + ClientAuthenticationMethodsEnum::ClientSecretBasic, + 'https://op.example.com/par', + ['request_uri' => 'urn:should:not:be:here'], + 'client-id', + 'client-secret', + ); + } + + public function testPushAuthorizationRequestSuccessWithPrivateKeyJwt(): void + { + $this->guzzleBridgeMock->expects($this->once()) + ->method('psr7StreamFor') + ->willReturnCallback( + function ( + callable|float|StreamInterface|bool|\Iterator|int|string|null $body, + ): MockObject&StreamInterface { + parse_str((string) $body, $params); + // client_id is forced into the body. + $this->assertSame('client-id', $params['client_id']); + // response_type is pushed. + $this->assertSame('code', $params['response_type']); + // private_key_jwt client authentication params are present. + $this->assertSame( + ClientAssertionTypesEnum::JwtBaerer->value, + $params['client_assertion_type'], + ); + $this->assertSame('assertion', $params['client_assertion']); + // request_uri must not be in the body. + $this->assertArrayNotHasKey('request_uri', $params); + return $this->createMock(StreamInterface::class); + } + ); + + $request = $this->createMock(RequestInterface::class); + $this->requestFactoryMock->method('createRequest')->willReturn($request); + $request->method('withBody')->willReturn($request); + $request->method('withHeader')->willReturn($request); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(201); + $responseBody = $this->createMock(StreamInterface::class); + $responseBody->method('__toString')->willReturn( + '{"request_uri": "urn:ietf:params:oauth:request_uri:abc", "expires_in": 60}' + ); + $response->method('getBody')->willReturn($responseBody); + + $this->httpClientMock->method('sendRequest')->willReturn($response); + + $result = $this->sut()->pushAuthorizationRequest( + ClientAuthenticationMethodsEnum::PrivateKeyJwt, + 'https://op.example.com/par', + [ + 'response_type' => 'code', + 'redirect_uri' => 'https://client.example.com/cb', + 'scope' => 'openid', + ], + 'client-id', + null, + 'assertion', + ); + + $this->assertSame( + ['request_uri' => 'urn:ietf:params:oauth:request_uri:abc', 'expires_in' => 60], + $result, + ); + } + + public function testPushAuthorizationRequestWithClientSecretBasicAddsHeader(): void + { + $this->guzzleBridgeMock->method('psr7StreamFor')->willReturn($this->createStub(StreamInterface::class)); + + $request = $this->createMock(RequestInterface::class); + $this->requestFactoryMock->method('createRequest')->willReturn($request); + $request->method('withBody')->willReturn($request); + $request->expects($this->exactly(3)) + ->method('withHeader') + ->willReturnCallback(function (string $name, $value) use ($request): MockObject { + if ($name === 'Authorization') { + $this->assertSame('Basic ' . base64_encode('client-id:client-secret'), $value); + } + + return $request; + }); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(201); + $responseBody = $this->createMock(StreamInterface::class); + $responseBody->method('__toString')->willReturn('{"request_uri": "urn:abc", "expires_in": 90}'); + $response->method('getBody')->willReturn($responseBody); + + $this->httpClientMock->method('sendRequest')->willReturn($response); + + $result = $this->sut()->pushAuthorizationRequest( + ClientAuthenticationMethodsEnum::ClientSecretBasic, + 'https://op.example.com/par', + ['response_type' => 'code'], + 'client-id', + 'client-secret', + ); + + $this->assertSame(['request_uri' => 'urn:abc', 'expires_in' => 90], $result); + } + + public function testPushAuthorizationRequestSurfacesJsonError(): void + { + $this->guzzleBridgeMock->method('psr7StreamFor')->willReturn($this->createStub(StreamInterface::class)); + + $request = $this->createMock(RequestInterface::class); + $this->requestFactoryMock->method('createRequest')->willReturn($request); + $request->method('withBody')->willReturn($request); + $request->method('withHeader')->willReturn($request); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(400); + $response->method('getReasonPhrase')->willReturn('Bad Request'); + $responseBody = $this->createMock(StreamInterface::class); + $responseBody->method('__toString')->willReturn( + '{"error": "invalid_request", "error_description": "client_id is missing"}' + ); + $response->method('getBody')->willReturn($responseBody); + + $this->httpClientMock->method('sendRequest')->willReturn($response); + + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage( + 'Pushed authorization request rejected by OpenID Provider - error "invalid_request": client_id is missing.' + ); + + $this->sut()->pushAuthorizationRequest( + ClientAuthenticationMethodsEnum::ClientSecretBasic, + 'https://op.example.com/par', + ['response_type' => 'code'], + 'client-id', + 'client-secret', + ); + } + + public function testValidatePushedAuthorizationResponseDataThrowsOnMissingRequestUri(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage('does not contain a valid "request_uri"'); + + $this->sut()->validatePushedAuthorizationResponseData(['expires_in' => 60]); + } + + public function testValidatePushedAuthorizationResponseDataDefaultsExpiresInToZero(): void + { + $this->assertSame( + ['request_uri' => 'urn:abc', 'expires_in' => 0], + $this->sut()->validatePushedAuthorizationResponseData(['request_uri' => 'urn:abc']), + ); + } }