OIDC login fails with "OIDC token exchange failed" when the browser retries the callback
What happens
An OIDC login ends on the plain page
OIDC token exchange failed. Check the Wallos server logs for details.and the server log shows
[Wallos OIDC] Token exchange failed for <provider host>: HTTP 400: {"error": "invalid_grant", ...}The login itself succeeded. Signing in again works, so it looks like an intermittent provider problem — it is not.
Measured
On an instance behind a reverse proxy, with Authentik as the provider, over a mobile connection. Times are from the proxy and the provider's own log, with codes and tokens redacted:
05:19:58.665 provider issues the authorization code
GET /?code=…&state=… -> 499 after 61 ms (client went away)
GET /?code=…&state=… -> 200 after 3181 ms, 69 bytes
05:20:00.907 POST /application/o/token/ -> 200 (first request)
05:20:01.940 POST /application/o/token/ -> 400 invalid_grant, "Code does not exist"
05:23:06 a new sign-in from the same client succeeds69 bytes is the length of the die() message above.
So: the browser sent the callback, gave up on it after 61 ms, and sent it again. Both requests carried the same authorization code, which a provider redeems exactly once. Once in 37 hours on that instance — an unstable connection is what triggers it.
Why both requests get that far
includes/checksession.php validates the state and removes it in memory:
unset($_SESSION['oidc_state']);
require_once 'includes/oidc/handle_oidc_callback.php';PHP writes the session at the end of the request, and the sign-in in includes/oidc/oidc_login.php calls session_regenerate_id(true) before that, which deletes the file the removal would have been written to. So a second request carrying the same callback — waiting on the session lock while the first one talks to the provider — is handed the state as it stood before it was consumed, passes the hash_equals check, and redeems the same code again.
PHP-FPM finishes the first request even though the client is gone, so the abandoned one is the one that does the real work.
Why the person is not signed in afterwards
The first request did sign them in — under a new session id, because session_regenerate_id(true) had just run. The response carrying that new cookie went to the connection the browser had already abandoned. The browser keeps sending the id it has, which after the regeneration names nothing at all, so the account is signed in under an id nobody knows and the person is asked to sign in again.
That is the second half of this, and it is why the symptom reads as "login broken" rather than "one request failed".
Suggested fix
Two independent parts:
Persist the consumption before the exchange.
session_write_close()right after the state is removed, thensession_start()again. A second callback is then handed the state after the consumption, refuses, and the code is never redeemed twice. Small and self-contained; I will open a pull request for this one.Do not delete a session whose response may not have arrived. Regenerate without deleting and leave the old session as a short-lived pointer to the new one, as the PHP manual describes for
session_regenerate_id()on unstable networks. A retry then lands in the session the first attempt established instead of at the login page. This touches the sign-in path more widely, so it is worth deciding separately.
Happy to prepare the second part as well if the shape above is what you would want.
Source: ellite/Wallos