QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun changement de comportement, aucune ligne de logique touchee. 72 fichiers, 4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur les commits voisins reste lisible. `quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui evite le brassage guillemets simples / doubles : le diff porte sur les retours a la ligne, l indentation des appels longs et les virgules finales, pas sur le style de chaine. Verification : 263 tests passent avant et apres, ruff check propre. L activation en CI arrive dans le commit suivant, separement, pour que ce diff-ci ne contienne rien d autre. Co-Authored-By: Claude Opus 5 <[email protected]>
126 lines
4.9 KiB
Python
126 lines
4.9 KiB
Python
"""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
|