request(); $url = $this->client()->authorizationUrl($request); parse_str((string) parse_url($url, \PHP_URL_QUERY), $query); self::assertSame('S256', $query['code_challenge_method'] ?? null); self::assertNotEmpty($query['code_challenge'] ?? ''); // The verifier itself never leaves the session -- what went out is its hash. $verifier = $request->getSession()->get('oauth2pkce'); self::assertIsString($verifier); self::assertSame( rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='), $query['code_challenge'], ); self::assertSame($query['state'] ?? null, $request->getSession()->get('oauth2state')); } /** * A callback that myep explained. Reporting it as a missing code would tell the person * something untrue and lose the reason. */ public function testARefusedAuthorizationIsReportedAsSuch(): void { $request = $this->callbackRequest(['error' => 'access_denied', 'error_description' => 'Nope.']); try { $this->client()->fetchAccessToken($request); self::fail('Expected the callback to be refused.'); } catch (AuthorizationDeniedException $e) { self::assertSame('access_denied', $e->error); self::assertSame('Nope.', $e->errorDescription); } } public function testAMismatchedStateIsRefused(): void { $this->expectException(AuthorizationRequestException::class); $this->expectExceptionMessage('state parameter'); $this->client()->fetchAccessToken($this->callbackRequest(['code' => 'c', 'state' => 'somebody else\'s'])); } /** * The classic "no state in session means pass" bug. */ public function testACallbackIntoASessionThatStartedNoFlowIsRefused(): void { $this->expectException(AuthorizationRequestException::class); $request = $this->callbackRequest(['code' => 'c', 'state' => 'anything'], state: null); $this->client()->fetchAccessToken($request); } public function testACallbackWithoutTheVerifierIsRefusedRatherThanExchanged(): void { $this->expectException(AuthorizationRequestException::class); $this->expectExceptionMessage('PKCE verifier'); $this->client()->fetchAccessToken($this->callbackRequest(['code' => 'c', 'state' => 'st'], verifier: null)); } /** * Both are consumed whatever the outcome, so the same callback cannot be replayed. */ public function testTheStateAndTheVerifierAreConsumedEvenOnAFailedCallback(): void { $request = $this->callbackRequest(['state' => 'st']); try { $this->client()->fetchAccessToken($request); } catch (AuthorizationRequestException) { } self::assertFalse($request->getSession()->has('oauth2state')); self::assertFalse($request->getSession()->has('oauth2pkce')); } private function client(): EpClient { $urls = $this->createStub(UrlGeneratorInterface::class); $urls->method('generate')->willReturn('https://ephub.test/auth/check'); return new EpClient($urls, [ 'client_id' => 'id', 'client_secret' => 'secret', 'url_authorize' => 'https://myep.test/authorize', 'url_access_token' => 'https://myep.test/token', 'url_userinfo' => 'https://myep.test/api/userinfo', 'scopes' => ['email', 'roles'], ]); } private function request(): Request { $request = new Request(); $request->setSession(new Session(new MockArraySessionStorage())); return $request; } /** * A callback as it arrives from myep, into a session that started the flow. * * @param array $query */ private function callbackRequest(array $query, ?string $state = 'st', ?string $verifier = 'verifier'): Request { $request = $this->request(); $request->query->replace($query); if (null !== $state) { $request->getSession()->set('oauth2state', $state); } if (null !== $verifier) { $request->getSession()->set('oauth2pkce', $verifier); } return $request; } }