fix(auth): ajouter le parametre state au flux OAuth2 Discord

SEC-AUTH-004. L'URL d'autorisation ne portait que client_id, redirect_uri,
response_type et scope. Sans state, le callback acceptait n'importe quel
code d'autorisation qu'on lui presentait.

Scenario ferme : un attaquant obtient un code pour SON compte Discord, puis
fait charger l'URL de callback par le navigateur de la victime. Le
formulaire d'inscription de la victime se retrouve pre-rempli avec
l'identite Discord de l'attaquant. C'est le login CSRF decrit par la
RFC 6749 §10.12.

Mise en oeuvre
  secrets.token_urlsafe(32) genere le jeton, stocke en session avant la
  redirection. Le callback le compare en temps constant avec
  secrets.compare_digest, et le consomme systematiquement -- valide ou non --
  pour qu'il ne puisse pas etre rejoue. Le controle intervient avant
  l'echange du code : un callback rejete ne declenche aucun appel reseau.

Deux corrections accessoires sur le meme chemin
  - DISCORD_REDIRECT_URI est desormais verifie au meme titre que
    DISCORD_CLIENT_ID. Non defini, il faisait lever requests.utils.quote(None)
    au lieu de signaler un probleme de configuration.
  - la construction de la chaine de requete passe a urlencode() plutot qu'a
    une concatenation manuelle.

9 tests : presence du state, stockage en session, unicite entre deux
demandes, rejet sans state, avec un state forge, sans demande prealable,
et non-rejouabilite.

Reste ouvert : l'identite Discord obtenue reste ensuite reinjectee par un
champ cache du formulaire (SEC-AUTH-005). Le state protege la liaison, pas
encore la valeur elle-meme.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-07 20:01:24 -04:00
co-authored by Claude Opus 5
parent d05e9cde32
commit 983b7a1f49
2 changed files with 167 additions and 4 deletions
+123
View File
@@ -0,0 +1,123 @@
"""Discord OAuth2 flow.
SEC-AUTH-004. The authorization URL carried client_id, redirect_uri,
response_type and scope — and nothing else. With no `state`, the callback
accepted any authorization code presented to it, so an attacker could have
a victim's browser consume a code issued for the attacker's own Discord
account (RFC 6749 §10.12, "login CSRF").
"""
from urllib.parse import parse_qs, urlparse
import pytest
from app.routes import auth as auth_module
@pytest.fixture
def discord_configured(monkeypatch):
"""Pretend the Discord application credentials are present.
They are read into module-level constants at import time, so they are
patched on the module rather than in the environment.
"""
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678')
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_SECRET', 'not-a-real-secret')
monkeypatch.setattr(
auth_module, 'DISCORD_REDIRECT_URI',
'https://example.test/auth/discord/callback',
)
def _authorize_params(response):
return parse_qs(urlparse(response.headers['Location']).query)
class TestAuthorizationRequest:
def test_the_request_carries_a_state(self, client, discord_configured):
response = client.get('/auth/discord/login', follow_redirects=False)
assert response.status_code in (301, 302)
params = _authorize_params(response)
assert 'state' in params
assert len(params['state'][0]) >= 32
def test_the_state_is_stored_in_the_session(self, client, discord_configured):
response = client.get('/auth/discord/login', follow_redirects=False)
sent = _authorize_params(response)['state'][0]
with client.session_transaction() as sess:
assert sess[auth_module.DISCORD_STATE_KEY] == sent
def test_two_requests_get_different_states(self, client, discord_configured):
first = _authorize_params(
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
second = _authorize_params(
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
assert first != second
def test_scopes_and_redirect_are_preserved(self, client, discord_configured):
params = _authorize_params(
client.get('/auth/discord/login', follow_redirects=False))
assert params['scope'][0] == 'identify connections'
assert params['response_type'][0] == 'code'
assert params['redirect_uri'][0] == 'https://example.test/auth/discord/callback'
def test_a_missing_redirect_uri_is_reported_not_raised(self, client, monkeypatch):
"""Quoting an unset redirect_uri used to raise inside the query
builder instead of reporting a configuration problem."""
monkeypatch.setattr(auth_module, 'DISCORD_CLIENT_ID', '123456789012345678')
monkeypatch.setattr(auth_module, 'DISCORD_REDIRECT_URI', None)
response = client.get('/auth/discord/login', follow_redirects=False)
assert response.status_code in (301, 302)
assert '/auth/register' in response.headers['Location']
class TestCallbackStateValidation:
"""The callback must reject anything it did not itself initiate.
None of these reach Discord: the state check happens before the token
exchange, so a rejected callback performs no network call.
"""
def test_a_callback_without_state_is_rejected(self, client, discord_configured):
client.get('/auth/discord/login', follow_redirects=False)
response = client.get(
'/auth/discord/callback?code=attacker-code', follow_redirects=False)
assert '/auth/register' in response.headers['Location']
def test_a_callback_with_a_wrong_state_is_rejected(self, client, discord_configured):
client.get('/auth/discord/login', follow_redirects=False)
response = client.get(
'/auth/discord/callback?code=attacker-code&state=forged',
follow_redirects=False)
assert '/auth/register' in response.headers['Location']
with client.session_transaction() as sess:
assert 'discord_oauth' not in sess
def test_a_callback_without_a_prior_request_is_rejected(self, client, discord_configured):
"""No /discord/login beforehand: nothing to match against."""
response = client.get(
'/auth/discord/callback?code=x&state=anything', follow_redirects=False)
assert '/auth/register' in response.headers['Location']
def test_the_state_is_single_use(self, client, discord_configured):
"""Consumed on the first callback, valid or not, so it cannot be
replayed."""
state = _authorize_params(
client.get('/auth/discord/login', follow_redirects=False))['state'][0]
client.get(f'/auth/discord/callback?code=x&state={state}',
follow_redirects=False)
with client.session_transaction() as sess:
assert auth_module.DISCORD_STATE_KEY not in sess