fix: verify the oauth2 state before the error parameter

This commit is contained in:
2026-09-23 17:48:24 +02:00
parent 3f4586ce06
commit f48e53fdd0
2 changed files with 46 additions and 23 deletions
+26 -22
View File
@@ -61,30 +61,15 @@ class MyEpClient
$expectedState = $session->remove(self::SESSION_KEY_STATE);
$pkceCode = $session->remove(self::SESSION_KEY_PKCE);
// Before anything else: MyE&P has said why it is not sending a code, and every
// later check would report the wrong cause. The role gate renders on MyE&P rather
// than redirecting, but a cancelled or refused authorization arrives here.
$error = $request->query->get('error');
if (true === is_string($error) && '' !== $error) {
$description = $request->query->get('error_description');
$description = is_string($description) && '' !== $description ? $description : null;
$this->logger->error('OAuth2 login request denied', [
'error' => $error,
'error_description' => $description,
]);
throw new AuthorizationDeniedException($error, $description, $request);
}
if (null === $code = $request->query->get('code')) {
$this->logger->error('OAuth2 login request missing code');
throw new AuthorizationRequestException('Missing code', 400, $request);
}
$state = $request->query->get('state');
// Before anything else, including the error parameter: RFC 6749 4.1.2.1 asks for the
// state to be verified on an error response too, and there is a practical reason to.
// The session cookie is SameSite=lax, so a top-level navigation to this route carries
// the victim's cookie, and the two removes above have already consumed their pending
// flow. Refusing here means a callback that cannot prove the state no longer destroys
// a login it did not start.
//
// hash_equals() rather than !==, the state being the CSRF secret of the flow. The
// string checks come first: a callback with no state, or a session that never held
// one, must fail here rather than reach a comparison with null.
@@ -97,6 +82,25 @@ class MyEpClient
throw new AuthorizationRequestException('Missing state or mismatch', 400, $request);
}
// Now that the callback is known to belong to this flow: MyE&P has said why it is
// not sending a code, and every later check would report the wrong cause. The role
// gate renders on MyE&P rather than redirecting, but a cancelled or refused
// authorization arrives here. Not logged — this is no failure of ours, and the
// caller decides what to make of it from the exception.
$error = $request->query->get('error');
if (true === is_string($error) && '' !== $error) {
$description = $request->query->get('error_description');
$description = is_string($description) && '' !== $description ? $description : null;
throw new AuthorizationDeniedException($error, $description, $request);
}
if (null === $code = $request->query->get('code')) {
$this->logger->error('OAuth2 login request missing code');
throw new AuthorizationRequestException('Missing code', 400, $request);
}
// A challenge went out with the authorization request, so MyE&P rejects an exchange
// without the verifier. Refused here instead, where the reason is still known — the
// usual cause is a session replaced between the two legs of the flow.