createRequest(); $url = $this->createClient()->createAuthorizationUrl($request); parse_str((string) parse_url($url, \PHP_URL_QUERY), $query); $this->assertSame('S256', $query['code_challenge_method']); $session = $request->getSession(); $verifier = $session->get('oauth2pkce'); $this->assertIsString($verifier); $this->assertSame($session->get('oauth2state'), $query['state']); // the challenge is what MyE&P stores; the verifier replayed on the token request // has to hash to it, or the exchange is refused $expectedChallenge = trim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); $this->assertSame($expectedChallenge, $query['code_challenge']); } public function testDeniedCallbackIsReportedAsADenialAndConsumesTheStoredState(): void { $request = $this->createRequest([ 'state' => 'expected-state', 'error' => 'access_denied', 'error_description' => 'The user denied the request', ]); $request->getSession()->set('oauth2state', 'expected-state'); $request->getSession()->set('oauth2pkce', 'verifier'); try { $this->createClient()->fetchAccessToken($request); $this->fail('Expected an AuthorizationDeniedException'); } catch (AuthorizationDeniedException $e) { // MyE&P has named the cause; reporting this as a missing code would not $this->assertSame('access_denied', $e->getError()); $this->assertSame('The user denied the request', $e->getErrorDescription()); } $this->assertFalse($request->getSession()->has('oauth2state')); $this->assertFalse($request->getSession()->has('oauth2pkce')); } public function testCallbackIsRefusedWhenTheSessionHoldsNoVerifier(): void { $request = $this->createRequest(['code' => 'a-code', 'state' => 'expected-state']); $request->getSession()->set('oauth2state', 'expected-state'); $this->expectException(AuthorizationRequestException::class); $this->expectExceptionMessage('Missing PKCE verifier'); $this->createClient()->fetchAccessToken($request); } public function testCallbackWithoutCodeConsumesTheStoredState(): void { $request = $this->createRequest(['state' => 'expected-state']); $request->getSession()->set('oauth2state', 'expected-state'); $request->getSession()->set('oauth2pkce', 'verifier'); try { $this->createClient()->fetchAccessToken($request); $this->fail('Expected an AuthorizationRequestException'); } catch (AuthorizationRequestException $e) { $this->assertSame('Missing code', $e->getMessage()); } // the state is single-use: a callback that fails must not leave one behind that a // later attempt could still replay $this->assertFalse($request->getSession()->has('oauth2state')); $this->assertFalse($request->getSession()->has('oauth2pkce')); } public function testCallbackWithAMismatchedStateConsumesTheStoredState(): void { $request = $this->createRequest(['code' => 'a-code', 'state' => 'other-state']); $request->getSession()->set('oauth2state', 'expected-state'); $request->getSession()->set('oauth2pkce', 'verifier'); $this->expectException(AuthorizationRequestException::class); $this->expectExceptionMessage('Missing state or mismatch'); try { $this->createClient()->fetchAccessToken($request); } finally { $this->assertFalse($request->getSession()->has('oauth2state')); $this->assertFalse($request->getSession()->has('oauth2pkce')); } } public function testCallbackIsRefusedWhenTheSessionHoldsNoState(): void { $request = $this->createRequest(['code' => 'a-code', 'state' => 'any-state']); $this->expectException(AuthorizationRequestException::class); $this->expectExceptionMessage('Missing state or mismatch'); $this->createClient()->fetchAccessToken($request); } private function createClient(): MyEpClient { $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $urlGenerator ->method('generate') ->willReturn('https://myep-team.example.org/myep-auth/check') ; return new MyEpClient($urlGenerator, $this->createMock(LoggerInterface::class), [ 'myep_oauth2_client_id' => 'client-id', 'myep_oauth2_client_secret' => 'client-secret', 'myep_oauth2_url_authorize' => 'https://my.example.org/authorize', 'myep_oauth2_url_access_token' => 'https://my.example.org/token', 'myep_oauth2_url_resource_owner_details' => 'https://my.example.org/api/userinfo', 'myep_oauth2_scopes' => ['email', 'id', 'roles', 'profile'], ]); } /** * @param array $query */ private function createRequest(array $query = []): Request { $request = new Request($query); $request->setSession(new Session(new MockArraySessionStorage())); return $request; } }