From b17993c2acaf7ec84bcc4aff132d7116de1ef040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 3 Jun 2026 13:48:57 +0200 Subject: [PATCH] Add support for response_mode parameter --- composer.json | 2 +- docs/2-Pre-Registered-Client.md | 17 ++- docs/3-Federated-Client.md | 19 ++- .../AuthorizationRequestMethodEnum.php | 15 +++ src/FederatedClient.php | 25 ++++ src/PreRegisteredClient.php | 22 ++++ src/Protocol/RequestDataHandler.php | 7 +- tests/Oidc/FederatedClientTest.php | 109 ++++++++++++++++++ tests/Oidc/PreRegisteredClientTest.php | 90 ++++++++++++++- .../Oidc/Protocol/RequestDataHandlerTest.php | 21 ++++ 10 files changed, 316 insertions(+), 11 deletions(-) diff --git a/composer.json b/composer.json index 2b5be24..67753a7 100644 --- a/composer.json +++ b/composer.json @@ -54,4 +54,4 @@ "vendor/bin/phpunit --no-coverage" ] } -} \ No newline at end of file +} diff --git a/docs/2-Pre-Registered-Client.md b/docs/2-Pre-Registered-Client.md index 8c3e742..21eff45 100644 --- a/docs/2-Pre-Registered-Client.md +++ b/docs/2-Pre-Registered-Client.md @@ -30,6 +30,7 @@ You can also customize the client behavior with optional parameters: ```php use SimpleSAML\OpenID\Codebooks\PkceCodeChallengeMethodEnum; +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; use Cicnavi\Oidc\PreRegisteredClient; use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; @@ -50,7 +51,8 @@ $oidcClient = new PreRegisteredClient( fetchUserinfoClaims: true, // Fetch claims from the userinfo endpoint maxCacheDuration: new \DateInterval('PT6H'), // Cache max TTL logger: null, // \Psr\Log\LoggerInterface instance - defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::FormPost // Determines the default authorization request method. + 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. ); ``` @@ -61,11 +63,17 @@ login process, you can use the `authorize()` method: ```php use Cicnavi\Oidc\PreRegisteredClient; +use Cicnavi\Oidc\CodeBooks\AuthorizationRequestMethodEnum; +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; /** @var PreRegisteredClient $oidcClient */ // File: authorize.php try { - $oidcClient->authorize(); + // You can also explicitly pass custom authorization request method and response mode: + $oidcClient->authorize( + authorizationRequestMethod: AuthorizationRequestMethodEnum::Query, + responseMode: ResponseModesEnum::Query + ); } catch (\Throwable $exception) { // In real app log the error, redirect user and show error message. throw $exception; @@ -78,8 +86,9 @@ server will initiate a browser redirection to the `redirect_uri` which was registered with the client (this is your callback). On the callback URI, you'll receive authorization `code` and `state` -(if state check is enabled) as GET parameters. To use that -authorization code, you can use the `getUserData()` method. +(if state check is enabled) as GET (for `query` response mode) or POST +(for `form_post` response mode) parameters. The `getUserData()` method +automatically handles both types of callbacks. This method will validate `state` (if `state` check is enabled) and send an HTTP request to token endpoint using the provided authorization `code` to retrieve tokens (access and ID token). After that it will try to diff --git a/docs/3-Federated-Client.md b/docs/3-Federated-Client.md index 79643d0..303953f 100644 --- a/docs/3-Federated-Client.md +++ b/docs/3-Federated-Client.md @@ -68,6 +68,14 @@ use FederatedClient\FederatedClientFactory; $config = require 'path/to/config.php'; $factory = new FederatedClientFactory($config, $logger, $cache); $client = $factory->build(); + +// Direct instantiation with custom response mode: +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; +$client = new FederatedClient( + entityConfig: $entityConfig, + relyingPartyConfig: $relyingPartyConfig, + responseMode: ResponseModesEnum::FormPost // Optional +); ``` ### 2. Initiating Authentication @@ -77,19 +85,22 @@ method takes the Entity ID of the OpenID Provider the user wants to log in with. ```php +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; + public function login(string $opEntityId) { /** @var \Cicnavi\Oidc\FederatedClient $client */ // This will resolve the trust chain, register the client if needed, - // and initiate the authentication flow. - $client->autoRegisterAndAuthenticate($opEntityId); + // and initiate the authentication flow. You can optionally specify a response mode: + $client->autoRegisterAndAuthenticate($opEntityId, responseMode: ResponseModesEnum::FormPost); } ``` ### 3. Handling the Callback After the user authenticates at the OP, they are redirected back to your -`redirect_uri`. Use the `getUserData` method to complete the flow and -collect user information. +`redirect_uri` via GET (for query response mode) or POST (for form_post response mode). +Use the `getUserData` method to complete the flow and collect user information. +The client automatically parses and handles both GET and POST requests. ```php diff --git a/src/CodeBooks/AuthorizationRequestMethodEnum.php b/src/CodeBooks/AuthorizationRequestMethodEnum.php index 40ecef6..88d74eb 100644 --- a/src/CodeBooks/AuthorizationRequestMethodEnum.php +++ b/src/CodeBooks/AuthorizationRequestMethodEnum.php @@ -4,6 +4,21 @@ namespace Cicnavi\Oidc\CodeBooks; +/** + * Specifies the method used by the Relying Party (RP) + * to deliver the authorization request TO the OpenID + * Provider (OP). + * + * This is orthogonal to the OIDC 'response_mode' parameter: + * - AuthorizationRequestMethodEnum controls the REQUEST + * delivery: sending it via HTTP GET (Query redirect) + * or via HTTP POST (FormPost auto-submit form). + * - The 'response_mode' parameter (ResponseModesEnum) + * controls the RESPONSE delivery: how the OP returns + * the authorization response back to the RP + * (e.g., via 'query' parameters or via 'form_post' + * POST body). + */ enum AuthorizationRequestMethodEnum { /** diff --git a/src/FederatedClient.php b/src/FederatedClient.php index 6ec3c66..a4f2348 100644 --- a/src/FederatedClient.php +++ b/src/FederatedClient.php @@ -41,6 +41,7 @@ use SimpleSAML\OpenID\Codebooks\EntityTypesEnum; use SimpleSAML\OpenID\Codebooks\GrantTypesEnum; use SimpleSAML\OpenID\Codebooks\HashAlgorithmsEnum; +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; use SimpleSAML\OpenID\Codebooks\ResponseTypesEnum; use SimpleSAML\OpenID\Codebooks\TokenEndpointAuthMethodsEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; @@ -141,9 +142,11 @@ public function __construct( ?RequestDataHandler $requestDataHandler = null, // phpcs:ignore protected readonly AuthorizationRequestMethodEnum $defaultAuthorizationRequestMethod = AuthorizationRequestMethodEnum::FormPost, + protected readonly ?ResponseModesEnum $responseMode = null, int $maxDiscoveryDepth = 10, ?EntityCollectionStoreInterface $entityCollectionStore = null, ) { + $this->validateResponseMode($this->responseMode); $this->cache = $cache ?? new FileCache('ofacpc-' . md5($this->entityConfig->getEntityId())); $this->signatureKeyPairFactory = $signatureKeyPairFactory ?? new SignatureKeyPairFactory($this->jwk); $this->signatureKeyPairBagFactory = $signatureKeyPairBagFactory ?? new SignatureKeyPairBagFactory( @@ -347,6 +350,10 @@ public function buildEntityStatement(): EntityStatement $rpMetadata[ClaimsEnum::RequestObjectSigningAlgValuesSupported->value] = $this->relyingPartyConfig->getConnectSignatureKeyPairBag()->getAllAlgorithmNamesUnique(); $rpMetadata[ClaimsEnum::Scope->value] = $this->relyingPartyConfig->getScopeBag()->toString(); + $rpMetadata[ClaimsEnum::ResponseModesSupported->value] = [ + ResponseModesEnum::Query->value, + ResponseModesEnum::FormPost->value, + ]; if ($this->includeSoftwareId) { $rpMetadata[ClaimsEnum::SoftwareId->value] = 'https://github.com/cicnavi/oidc-client-php'; } @@ -512,8 +519,11 @@ public function autoRegisterAndAuthenticate( ?ResponseInterface $response = null, ?string $clientRedirectUri = null, ?AuthorizationRequestMethodEnum $authorizationRequestMethod = null, + ?ResponseModesEnum $responseMode = null, ): ?ResponseInterface { $authorizationRequestMethod ??= $this->defaultAuthorizationRequestMethod; + $responseMode ??= $this->responseMode; + $this->validateResponseMode($responseMode); $trustAnchorBag = $this->entityConfig->getTrustAnchorBag(); if ($specificTrustAnchors instanceof TrustAnchorConfigBag) { $trustAnchorBag = $trustAnchorBag->getInCommonWith($specificTrustAnchors); @@ -685,6 +695,7 @@ public function autoRegisterAndAuthenticate( ParamsEnum::ResponseType->value => ResponseTypesEnum::Code->value, 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, @@ -724,6 +735,7 @@ public function autoRegisterAndAuthenticate( ParamsEnum::ResponseType->value => ResponseTypesEnum::Code->value, ParamsEnum::ClientId->value => $this->entityConfig->getEntityId(), ParamsEnum::RedirectUri->value => $clientRedirectUri, + ParamsEnum::ResponseMode->value => $responseMode?->value, ]); $this->logger?->debug( @@ -936,4 +948,17 @@ public function discoverEntities( return $entities; } + + /** + * @throws OidcClientException + */ + protected function validateResponseMode(?ResponseModesEnum $responseMode): void + { + if ($responseMode === ResponseModesEnum::Fragment) { + throw new OidcClientException( + "The 'fragment' response mode is not supported because URLs with fragments are " . + "not sent to the server and cannot be handled by a server-side PHP client." + ); + } + } } diff --git a/src/PreRegisteredClient.php b/src/PreRegisteredClient.php index 9462f66..7bc8ab2 100644 --- a/src/PreRegisteredClient.php +++ b/src/PreRegisteredClient.php @@ -26,6 +26,7 @@ use SimpleSAML\OpenID\Codebooks\ClientAuthenticationMethodsEnum; use SimpleSAML\OpenID\Codebooks\ParamsEnum; use SimpleSAML\OpenID\Codebooks\PkceCodeChallengeMethodEnum; +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; use SimpleSAML\OpenID\Codebooks\ResponseTypesEnum; use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Exceptions\InvalidValueException; @@ -127,8 +128,11 @@ public function __construct( protected readonly DateInterval $maxCacheDuration = new DateInterval('PT6H'), // phpcs:ignore protected readonly AuthorizationRequestMethodEnum $defaultAuthorizationRequestMethod = AuthorizationRequestMethodEnum::FormPost, + protected readonly ?ResponseModesEnum $responseMode = null, ?RequestDataHandler $requestDataHandler = null, ) { + $this->validateResponseMode($this->responseMode); + $this->cache = $cache ?? new FileCache('oprcpc-' . md5($this->clientId)); $this->validateCache(); @@ -195,8 +199,12 @@ protected function validateCache(): void public function authorize( ?AuthorizationRequestMethodEnum $authorizationRequestMethod = null, ?ResponseInterface $response = null, + ?ResponseModesEnum $responseMode = null, ): ?ResponseInterface { $authorizationRequestMethod ??= $this->defaultAuthorizationRequestMethod; + $responseMode ??= $this->responseMode; + + $this->validateResponseMode($responseMode); $state = $this->useState ? $this->requestDataHandler->getState() : null; $nonce = $this->useNonce ? $this->requestDataHandler->getNonce() : null; @@ -214,6 +222,7 @@ public function authorize( ParamsEnum::ClientId->value => $this->clientId, ParamsEnum::RedirectUri->value => $this->redirectUri, ParamsEnum::Scope->value => $this->scope, + ParamsEnum::ResponseMode->value => $responseMode?->value, ParamsEnum::State->value => $state, ParamsEnum::Nonce->value => $nonce, @@ -312,4 +321,17 @@ public function reinitializeCache(): void $this->cache->clear(); $this->cache->set(self::CACHE_KEY_OP_CONFIGURATION_URL, $this->opConfigurationUrl); } + + /** + * @throws OidcClientException + */ + protected function validateResponseMode(?ResponseModesEnum $responseMode): void + { + if ($responseMode === ResponseModesEnum::Fragment) { + throw new OidcClientException( + "The 'fragment' response mode is not supported because URLs with fragments are " . + "not sent to the server and cannot be handled by a server-side PHP client." + ); + } + } } diff --git a/src/Protocol/RequestDataHandler.php b/src/Protocol/RequestDataHandler.php index 178bbe3..2084aa4 100644 --- a/src/Protocol/RequestDataHandler.php +++ b/src/Protocol/RequestDataHandler.php @@ -159,7 +159,12 @@ public function validateAuthorizationCallbackResponse( ?ServerRequestInterface $request = null, bool $useState = true, ): array { - $params = $request?->getQueryParams() ?? $_GET; + $queryParams = $request?->getQueryParams() ?? $_GET; + $parsedBody = $request?->getParsedBody() ?? $_POST; + $params = array_merge( + $queryParams, + is_array($parsedBody) ? $parsedBody : [] + ); $error = $params[ParamsEnum::Error->value] ?? null; $errorDescription = $params[ParamsEnum::ErrorDescription->value] ?? null; diff --git a/tests/Oidc/FederatedClientTest.php b/tests/Oidc/FederatedClientTest.php index 8862d2a..65ff7d7 100644 --- a/tests/Oidc/FederatedClientTest.php +++ b/tests/Oidc/FederatedClientTest.php @@ -22,6 +22,7 @@ use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; use SimpleSAML\OpenID\Codebooks\HashAlgorithmsEnum; use SimpleSAML\OpenID\Codebooks\PkceCodeChallengeMethodEnum; +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Core\ClientAssertion; @@ -181,6 +182,7 @@ protected function sut( ?Jwks $jwks = null, ?RequestDataHandler $requestDataHandler = null, ?AuthorizationRequestMethodEnum $defaultAuthorizationRequestMethod = null, + ?ResponseModesEnum $responseMode = null, ): FederatedClient { $entityConfig ??= $this->entityConfigMock; $relyingPartyConfig ??= $this->realyingPartyConfigMock; @@ -242,6 +244,7 @@ protected function sut( $jwks, $requestDataHandler, $defaultAuthorizationRequestMethod, + $responseMode, ); } @@ -796,4 +799,110 @@ public function testResolveClientRedirectUriForAuthorizationRequest(): void $this->assertSame('https://rp.example.org/default', $method ->invoke($sut, 'https://rp.example.org/invalid')); } + + public function testConstructorThrowsIfResponseModeIsFragment(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage("The 'fragment' response mode is not supported"); + + $this->sut(responseMode: ResponseModesEnum::Fragment); + } + + public function testAutoRegisterAndAuthenticateThrowsIfResponseModeIsFragment(): void + { + $this->expectException(OidcClientException::class); + $this->expectExceptionMessage("The 'fragment' response mode is not supported"); + + $this->sut()->autoRegisterAndAuthenticate( + 'https://op.example.org', + responseMode: ResponseModesEnum::Fragment + ); + } + + public function testAutoRegisterAndAuthenticateSuccessRedirectWithResponseMode(): 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); + $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', + 'issuer' => $opEntityId, + ]; + $opTrustChainMock->method('getResolvedMetadata')->willReturn($opMetadata); + + $keyPairResolverMock = $this->createMock(\SimpleSAML\OpenID\Utils\KeyPairResolver::class); + $this->federationMock->method('keyPairResolver')->willReturn($keyPairResolverMock); + + $signingKeyPairMock = $this->createMock(SignatureKeyPair::class); + $keyPairResolverMock->method('resolveSignatureKeyPairByAlgorithm')->willReturn($signingKeyPairMock); + + $trustAnchorMock = $this->createMock(EntityStatement::class); + $opTrustChainMock->method('getResolvedTrustAnchor')->willReturn($trustAnchorMock); + $trustAnchorMock->method('getIssuer')->willReturn($trustAnchorId); + + $rpTrustChainBagMock = $this->createMock(TrustChainBag::class); + $rpTrustChainBagMock->method('getShortest')->willReturn($this->createStub(TrustChain::class)); + $trustChainResolverMock->method('for') + ->willReturnOnConsecutiveCalls($opTrustChainBagMock, $rpTrustChainBagMock); + + $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'); + + $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); + + $requestObjectFactoryMock = $this->createMock(RequestObjectFactory::class); + $this->federationMock->method('requestObjectFactory')->willReturn($requestObjectFactoryMock); + $requestObjectMock = $this->createMock(\SimpleSAML\OpenID\Federation\RequestObject::class); + $requestObjectFactoryMock->method('fromData')->willReturn($requestObjectMock); + $requestObjectMock->method('getToken')->willReturn('signed_request_object'); + + $responseMock = $this->createMock(ResponseInterface::class); + $responseMock->method('withHeader')->with('Location', $this->callback(fn(string $url): bool => + str_contains($url, 'response_mode=form_post')))->willReturn($responseMock); + + $result = $this->sut(defaultAuthorizationRequestMethod: AuthorizationRequestMethodEnum::Query) + ->autoRegisterAndAuthenticate( + $opEntityId, + response: $responseMock, + responseMode: ResponseModesEnum::FormPost + ); + $this->assertSame($responseMock, $result); + } } diff --git a/tests/Oidc/PreRegisteredClientTest.php b/tests/Oidc/PreRegisteredClientTest.php index db886c1..8545a06 100644 --- a/tests/Oidc/PreRegisteredClientTest.php +++ b/tests/Oidc/PreRegisteredClientTest.php @@ -9,11 +9,13 @@ use Cicnavi\Oidc\Interfaces\MetadataInterface; use Cicnavi\Oidc\PreRegisteredClient; use Cicnavi\Oidc\Protocol\RequestDataHandler; +use DateInterval; use GuzzleHttp\Client; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use SimpleSAML\OpenID\Codebooks\PkceCodeChallengeMethodEnum; +use SimpleSAML\OpenID\Codebooks\ResponseModesEnum; use SimpleSAML\OpenID\SupportedAlgorithms; use SimpleSAML\OpenID\SupportedSerializers; use Cicnavi\Oidc\Helpers\HttpHelper; @@ -119,8 +121,9 @@ protected function sut( ?MetadataInterface $metadata = null, ?\SimpleSAML\OpenID\Core $core = null, ?\SimpleSAML\OpenID\Jwks $jwks = null, - ?\DateInterval $maxCacheDuration = null, + ?DateInterval $maxCacheDuration = null, ?AuthorizationRequestMethodEnum $defaultAuthorizationRequestMethod = null, + ?ResponseModesEnum $responseMode = null, ?RequestDataHandler $requestDataHandler = null, ): PreRegisteredClient { return new PreRegisteredClient( @@ -146,6 +149,7 @@ protected function sut( $jwks ?? $this->jwksMock, $maxCacheDuration ?? $this->maxCacheDuration, $defaultAuthorizationRequestMethod ?? $this->defaultAuthorizationRequestMethod, + $responseMode, $requestDataHandler ?? $this->requestDataHandlerMock, ); } @@ -318,4 +322,88 @@ public function testGetUserDataThrowsIfMissingTokenEndpoint(): void $this->sut()->getUserData(); } + + public function testConstructorThrowsIfResponseModeIsFragment(): void + { + $this->expectException(\Cicnavi\Oidc\Exceptions\OidcClientException::class); + $this->expectExceptionMessage("The 'fragment' response mode is not supported"); + + $this->sut(responseMode: ResponseModesEnum::Fragment); + } + + public function testAuthorizeThrowsIfResponseModeIsFragment(): void + { + $this->expectException(\Cicnavi\Oidc\Exceptions\OidcClientException::class); + $this->expectExceptionMessage("The 'fragment' response mode is not supported"); + + $this->sut()->authorize( + authorizationRequestMethod: AuthorizationRequestMethodEnum::Query, + responseMode: ResponseModesEnum::Fragment + ); + } + + public function testAuthorizeQueryWithResponseMode(): void + { + $this->metadataMock->expects($this->once())->method('get')->willReturnMap([ + ['authorization_endpoint', 'https://auth.example.org/authorize'], + ]); + + $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'); + + $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_mode=query')) + ) + ->willReturn($response); + + $result = $this->sut()->authorize( + AuthorizationRequestMethodEnum::Query, + $response, + ResponseModesEnum::Query + ); + $this->assertSame($response, $result); + } + + public function testAuthorizeFormPostWithResponseMode(): void + { + $this->metadataMock->expects($this->once())->method('get')->willReturnMap([ + ['authorization_endpoint', 'https://auth.example.org/authorize'], + ]); + + $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'); + + $body = $this->createMock(\Psr\Http\Message\StreamInterface::class); + $body->expects($this->once()) + ->method('write') + ->with($this->callback(fn(string $html): bool => + str_contains($html, 'name="response_mode"') && str_contains($html, 'value="form_post"'))); + + $response = $this->createMock(\Psr\Http\Message\ResponseInterface::class); + $response->method('getBody')->willReturn($body); + $response->expects($this->once()) + ->method('withHeader') + ->with('Content-Type', 'text/html') + ->willReturn($response); + + $result = $this->sut()->authorize( + AuthorizationRequestMethodEnum::FormPost, + $response, + ResponseModesEnum::FormPost + ); + $this->assertSame($response, $result); + } } diff --git a/tests/Oidc/Protocol/RequestDataHandlerTest.php b/tests/Oidc/Protocol/RequestDataHandlerTest.php index 3939392..6ecd853 100644 --- a/tests/Oidc/Protocol/RequestDataHandlerTest.php +++ b/tests/Oidc/Protocol/RequestDataHandlerTest.php @@ -313,6 +313,27 @@ public function testValidateAuthorizationCallbackResponseVerifiesState(): void ], $result); } + public function testValidateAuthorizationCallbackResponseVerifiesStateFromParsedBody(): void + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getQueryParams')->willReturn([]); + $request->method('getParsedBody')->willReturn([ + 'code' => 'some-code', + 'state' => 'some-state', + ]); + + $this->stateNonceDataHandlerMock->expects($this->once()) + ->method('verify') + ->with(StateNonce::STATE_KEY, 'some-state'); + + $result = $this->sut()->validateAuthorizationCallbackResponse($request, true); + + $this->assertSame([ + 'code' => 'some-code', + 'state' => 'some-state', + ], $result); + } + public function testValidateHttpResponseOkThrowsOnNon200(): void { $response = $this->createMock(ResponseInterface::class);