From 19d81847757b94f9666f4678897a6f63e12cef2f Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Mon, 12 Oct 2026 15:33:44 +0200 Subject: [PATCH 1/3] [ENG-12178] Fix Mendeley OAuth: handle callback errors, retry py --- addon_service/common/network.py | 58 ++++++++++-- addon_service/common/urllib_fallback.py | 92 +++++++++++++++++++ addon_service/oauth2/utils.py | 59 +++++++++--- addon_service/oauth2/views.py | 8 +- .../tests/test_oauth2_callback_view.py | 19 ++++ 5 files changed, 216 insertions(+), 20 deletions(-) create mode 100644 addon_service/common/urllib_fallback.py create mode 100644 addon_service/tests/test_oauth2_callback_view.py diff --git a/addon_service/common/network.py b/addon_service/common/network.py index 49d15d90..378b95cf 100644 --- a/addon_service/common/network.py +++ b/addon_service/common/network.py @@ -14,7 +14,10 @@ import aiohttp from asgiref.sync import sync_to_async -from addon_service.common import exceptions +from addon_service.common import ( + exceptions, + urllib_fallback, +) from addon_service.common.credentials_formats import CredentialsFormats from addon_toolkit.constrained_network.http import ( HttpRequestInfo, @@ -61,6 +64,27 @@ async def text_content(self) -> str: return await _response.text() +class _BufferedResponseInfo(HttpResponseInfo): + """an imp-friendly face for a response already read into memory (see `urllib_fallback`)""" + + def __init__(self, response: urllib_fallback.BufferedResponse): + self._response = response + + @property + def http_status(self) -> HTTPStatus: + return HTTPStatus(self._response.status) + + @property + def headers(self) -> Multidict: + return Multidict(self._response.headers) + + async def json_content(self) -> typing.Any: + return self._response.json() + + async def text_content(self) -> str: + return self._response.text + + class GravyvaletHttpRequestor(HttpRequestor): """an `HttpRequestor` implementation using aiohttp""" @@ -109,13 +133,33 @@ async def _try_send(self, request: HttpRequestInfo): json=request.json, data=request.content, ) as _response: - if ( - _response.status == HTTPStatus.UNAUTHORIZED - and _private.account.credentials_format == CredentialsFormats.OAUTH2 + self._raise_if_expired_token(_response.status) + if not urllib_fallback.is_bot_challenge( + _response.status, _response.content_type ): - # Assume unauthorized because of token expiration. - raise exceptions.ExpiredAccessToken - yield _AiohttpResponseInfo(_response) + yield _AiohttpResponseInfo(_response) + return + # the provider's CDN challenged aiohttp: resend (it was not processed) with urllib + _logger.info(f"bot challenge from {_url}, retrying with urllib") + _buffered = await urllib_fallback.send( + request.http_method, + _url, + headers=combined_headers.items(), + params=request.query.items() if request.query else None, + json_body=request.json, + data=request.content, + ) + self._raise_if_expired_token(_buffered.status) + yield _BufferedResponseInfo(_buffered) + + def _raise_if_expired_token(self, status: int) -> None: + if ( + status == HTTPStatus.UNAUTHORIZED + and _PrivateNetworkInfo.get(self).account.credentials_format + == CredentialsFormats.OAUTH2 + ): + # Assume unauthorized because of token expiration. + raise exceptions.ExpiredAccessToken ### diff --git a/addon_service/common/urllib_fallback.py b/addon_service/common/urllib_fallback.py new file mode 100644 index 00000000..63bfef46 --- /dev/null +++ b/addon_service/common/urllib_fallback.py @@ -0,0 +1,92 @@ +"""fallback http sending for providers whose CDN challenges aiohttp requests + +Some providers (e.g. Mendeley, behind Cloudflare) answer aiohttp requests with a +"403 text/html" bot challenge, though the same request sent with stdlib `urllib` gets through. +""" + +import asyncio +import dataclasses +import json +import urllib.error +import urllib.parse +import urllib.request +from http import HTTPStatus +from typing import ( + Any, + Iterable, +) + + +__all__ = ("BufferedResponse", "is_bot_challenge", "send") + +USER_AGENT = "GravyValet/1.0" +_TIMEOUT_SECONDS = 30 + + +@dataclasses.dataclass +class BufferedResponse: + status: int + content_type: str + headers: list[tuple[str, str]] + body: bytes + charset: str = "utf-8" + + @property + def text(self) -> str: + return self.body.decode(self.charset) + + def json(self) -> Any: + return json.loads(self.text) + + +def is_bot_challenge(status: int, content_type: str | None) -> bool: + return status == HTTPStatus.FORBIDDEN and content_type == "text/html" + + +async def send( + method: str, + url: str, + *, + headers: Iterable[tuple[str, str]] = (), + params: Iterable[tuple[str, str]] | None = None, + json_body: Any = None, + data: str | bytes | dict | None = None, +) -> BufferedResponse: + return await asyncio.to_thread( + _send_blocking, str(method), url, headers, params, json_body, data + ) + + +def _send_blocking(method, url, headers, params, json_body, data) -> BufferedResponse: + _headers = dict(headers) + _headers["User-Agent"] = USER_AGENT + _body = None + if json_body is not None: + _body = json.dumps(json_body).encode() + _headers.setdefault("Content-Type", "application/json") + elif isinstance(data, dict): + _body = urllib.parse.urlencode(data).encode() + _headers.setdefault("Content-Type", "application/x-www-form-urlencoded") + elif isinstance(data, str): + _body = data.encode() + elif data is not None: + _body = data + if params: + _sep = "&" if urllib.parse.urlsplit(url).query else "?" + url = f"{url}{_sep}{urllib.parse.urlencode(list(params))}" + _request = urllib.request.Request(url, data=_body, method=method, headers=_headers) + try: + with urllib.request.urlopen(_request, timeout=_TIMEOUT_SECONDS) as _response: + return _buffer(_response, _response.status) + except urllib.error.HTTPError as _error: + return _buffer(_error, _error.code) + + +def _buffer(response, status: int) -> BufferedResponse: + return BufferedResponse( + status=status, + content_type=response.headers.get_content_type(), + headers=list(response.headers.items()), + body=response.read(), + charset=response.headers.get_content_charset() or "utf-8", + ) diff --git a/addon_service/oauth2/utils.py b/addon_service/oauth2/utils.py index aa605544..611fce45 100644 --- a/addon_service/oauth2/utils.py +++ b/addon_service/oauth2/utils.py @@ -4,6 +4,7 @@ from secrets import token_urlsafe from typing import Iterable +from addon_service.common import urllib_fallback from addon_service.common.aiohttp_session import get_singleton_client_session from addon_toolkit.iri_utils import iri_with_query @@ -120,19 +121,53 @@ async def get_refreshed_access_token( async def _token_request( token_endpoint_url: str, request_body: dict[str, str] ) -> FreshTokenResult: + _status, _content_type, _payload = await _post_token_request( + token_endpoint_url, request_body + ) + if _content_type == "application/x-www-form-urlencoded": + response_data = dict(urllib.parse.parse_qsl(_payload)) + if expires := response_data.get("expires_in"): + response_data["expires_in"] = int(expires) + + return FreshTokenResult.from_token_response_json(response_data) + if not HTTPStatus(_status).is_success: + raise RuntimeError( + f"token request to {token_endpoint_url} failed: {_status} {str(_payload)[:200]}" + ) + # TODO: https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2 + return FreshTokenResult.from_token_response_json(_payload) + + +async def _post_token_request( + token_endpoint_url: str, request_body: dict[str, str] +) -> tuple[int, str, str | dict]: + """returns (status, content_type, payload), payload being text unless json""" _client = await get_singleton_client_session() - async with _client.post(token_endpoint_url, data=request_body) as _token_response: - if _token_response.content_type == "application/x-www-form-urlencoded": - response_text = await _token_response.text() - response_data = dict(urllib.parse.parse_qsl(response_text)) - if expires := response_data.get("expires_in"): - response_data["expires_in"] = int(expires) - - return FreshTokenResult.from_token_response_json(response_data) - if not HTTPStatus(_token_response.status).is_success: - raise RuntimeError(await _token_response.json()) - # TODO: https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2 - return FreshTokenResult.from_token_response_json(await _token_response.json()) + async with _client.post(token_endpoint_url, data=request_body) as _response: + if not urllib_fallback.is_bot_challenge( + _response.status, _response.content_type + ): + return ( + _response.status, + _response.content_type, + ( + await _response.json() + if _response.content_type == "application/json" + else await _response.text() + ), + ) + _buffered = await urllib_fallback.send( + "POST", token_endpoint_url, data=request_body + ) + return ( + _buffered.status, + _buffered.content_type, + ( + _buffered.json() + if _buffered.content_type == "application/json" + else _buffered.text + ), + ) def _parse_scope_param_value(scope_value: str | None) -> list[str] | None: diff --git a/addon_service/oauth2/views.py b/addon_service/oauth2/views.py index 4c6d30ff..e13511e7 100644 --- a/addon_service/oauth2/views.py +++ b/addon_service/oauth2/views.py @@ -23,7 +23,13 @@ async def oauth2_callback_view(request): see https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2 """ - # TODO: handle error: https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2.1 + # https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2.1 + if "error" in request.GET: + return HttpResponse( + f"oauth2 authorization failed: {request.GET['error']}" + f" ({request.GET.get('error_description', 'no description')})", + status=HTTPStatus.BAD_REQUEST, + ) _state_token = request.GET["state"] _authorization_code = request.GET["code"] _token_metadata, _oauth_client_config = await _resolve_state_token(_state_token) diff --git a/addon_service/tests/test_oauth2_callback_view.py b/addon_service/tests/test_oauth2_callback_view.py new file mode 100644 index 00000000..fd64ff49 --- /dev/null +++ b/addon_service/tests/test_oauth2_callback_view.py @@ -0,0 +1,19 @@ +from http import HTTPStatus + +from django.test import SimpleTestCase +from django.urls import reverse + + +class TestOAuth2CallbackViewErrors(SimpleTestCase): + def test_error_response_from_provider(self): + _resp = self.client.get( + reverse("oauth2-callback"), + { + "error": "access_denied", + "error_description": "user said no", + "state": "1.nonce", + }, + ) + self.assertEqual(_resp.status_code, HTTPStatus.BAD_REQUEST) + self.assertIn("access_denied", _resp.content.decode()) + self.assertIn("user said no", _resp.content.decode()) From c2babf8cce72b0d0b87fd572485b75ecbe5e3a81 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Wed, 21 Oct 2026 15:30:50 +0200 Subject: [PATCH 2/3] [ENG-12178] Fix reflected XSS in OAuth2 callback error response --- addon_service/oauth2/views.py | 30 +++++++++++++++++-- .../tests/test_oauth2_callback_view.py | 27 ++++++++++------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/addon_service/oauth2/views.py b/addon_service/oauth2/views.py index e13511e7..9650c0e6 100644 --- a/addon_service/oauth2/views.py +++ b/addon_service/oauth2/views.py @@ -1,4 +1,5 @@ import asyncio +import logging from http import HTTPStatus from asgiref.sync import sync_to_async @@ -13,6 +14,22 @@ from addon_service.oauth2.utils import get_initial_access_token +_logger = logging.getLogger(__name__) + +# https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2.1 +_KNOWN_ERROR_CODES = frozenset( + ( + "invalid_request", + "unauthorized_client", + "access_denied", + "unsupported_response_type", + "invalid_scope", + "server_error", + "temporarily_unavailable", + ) +) + + # Exclude oAuth views from openapi schema as they are from internal use only @extend_schema(exclude=True) @transaction.non_atomic_requests # async views and ATOMIC_REQUESTS do not mix @@ -23,12 +40,19 @@ async def oauth2_callback_view(request): see https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2 """ - # https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2.1 if "error" in request.GET: + # query params are attacker-controlled: never reflect them, and never as html + _error = request.GET["error"] + _logger.warning( + "oauth2 callback error=%r error_description=%r", + _error[:100], + request.GET.get("error_description", "")[:200], + ) return HttpResponse( - f"oauth2 authorization failed: {request.GET['error']}" - f" ({request.GET.get('error_description', 'no description')})", + "oauth2 authorization failed" + + (f": {_error}" if _error in _KNOWN_ERROR_CODES else ""), status=HTTPStatus.BAD_REQUEST, + content_type="text/plain; charset=utf-8", ) _state_token = request.GET["state"] _authorization_code = request.GET["code"] diff --git a/addon_service/tests/test_oauth2_callback_view.py b/addon_service/tests/test_oauth2_callback_view.py index fd64ff49..4efe2f1f 100644 --- a/addon_service/tests/test_oauth2_callback_view.py +++ b/addon_service/tests/test_oauth2_callback_view.py @@ -5,15 +5,22 @@ class TestOAuth2CallbackViewErrors(SimpleTestCase): - def test_error_response_from_provider(self): - _resp = self.client.get( - reverse("oauth2-callback"), - { - "error": "access_denied", - "error_description": "user said no", - "state": "1.nonce", - }, - ) + def _get_error(self, **params): + return self.client.get(reverse("oauth2-callback"), {"state": "1.n", **params}) + + def test_known_error_code(self): + _resp = self._get_error(error="access_denied", error_description="no thanks") self.assertEqual(_resp.status_code, HTTPStatus.BAD_REQUEST) self.assertIn("access_denied", _resp.content.decode()) - self.assertIn("user said no", _resp.content.decode()) + self.assertNotIn("no thanks", _resp.content.decode()) + + def test_error_params_are_not_reflected(self): + with self.assertLogs("addon_service.oauth2.views", "WARNING"): + _resp = self._get_error( + error="", + error_description="", + ) + self.assertEqual(_resp.status_code, HTTPStatus.BAD_REQUEST) + self.assertTrue(_resp["Content-Type"].startswith("text/plain")) + self.assertNotIn(" Date: Fri, 23 Oct 2026 16:07:44 +0200 Subject: [PATCH 3/3] [ENG-12178] Send GravyValet user-agent with aiohttp instead of urllib fallback --- addon_service/common/aiohttp_session.py | 5 + addon_service/common/network.py | 58 ++---------- addon_service/common/urllib_fallback.py | 92 ------------------- addon_service/oauth2/utils.py | 59 +++--------- .../tests/test_oauth2_callback_view.py | 16 ++++ 5 files changed, 40 insertions(+), 190 deletions(-) delete mode 100644 addon_service/common/urllib_fallback.py diff --git a/addon_service/common/aiohttp_session.py b/addon_service/common/aiohttp_session.py index 6f83274f..9cf356d1 100644 --- a/addon_service/common/aiohttp_session.py +++ b/addon_service/common/aiohttp_session.py @@ -14,6 +14,10 @@ "close_singleton_client_session__blocking", ) +# an identifying user-agent: some providers' CDNs (e.g. Mendeley, behind Cloudflare) +# challenge or block requests sent with aiohttp's default `Python/x.y aiohttp/z` user-agent +USER_AGENT = "GravyValet/1.0" + __SINGLETON_CLIENT_SESSION_STORE: MutableMapping[threading.Thread, ClientSession] = ( weakref.WeakKeyDictionary() ) @@ -30,6 +34,7 @@ async def get_singleton_client_session() -> aiohttp.ClientSession: if thread_id not in __SINGLETON_CLIENT_SESSION_STORE: __SINGLETON_CLIENT_SESSION_STORE[thread_id] = aiohttp.ClientSession( cookie_jar=aiohttp.DummyCookieJar(), # ignore all cookies + headers={"User-Agent": USER_AGENT}, ) return __SINGLETON_CLIENT_SESSION_STORE[thread_id] diff --git a/addon_service/common/network.py b/addon_service/common/network.py index 378b95cf..49d15d90 100644 --- a/addon_service/common/network.py +++ b/addon_service/common/network.py @@ -14,10 +14,7 @@ import aiohttp from asgiref.sync import sync_to_async -from addon_service.common import ( - exceptions, - urllib_fallback, -) +from addon_service.common import exceptions from addon_service.common.credentials_formats import CredentialsFormats from addon_toolkit.constrained_network.http import ( HttpRequestInfo, @@ -64,27 +61,6 @@ async def text_content(self) -> str: return await _response.text() -class _BufferedResponseInfo(HttpResponseInfo): - """an imp-friendly face for a response already read into memory (see `urllib_fallback`)""" - - def __init__(self, response: urllib_fallback.BufferedResponse): - self._response = response - - @property - def http_status(self) -> HTTPStatus: - return HTTPStatus(self._response.status) - - @property - def headers(self) -> Multidict: - return Multidict(self._response.headers) - - async def json_content(self) -> typing.Any: - return self._response.json() - - async def text_content(self) -> str: - return self._response.text - - class GravyvaletHttpRequestor(HttpRequestor): """an `HttpRequestor` implementation using aiohttp""" @@ -133,33 +109,13 @@ async def _try_send(self, request: HttpRequestInfo): json=request.json, data=request.content, ) as _response: - self._raise_if_expired_token(_response.status) - if not urllib_fallback.is_bot_challenge( - _response.status, _response.content_type + if ( + _response.status == HTTPStatus.UNAUTHORIZED + and _private.account.credentials_format == CredentialsFormats.OAUTH2 ): - yield _AiohttpResponseInfo(_response) - return - # the provider's CDN challenged aiohttp: resend (it was not processed) with urllib - _logger.info(f"bot challenge from {_url}, retrying with urllib") - _buffered = await urllib_fallback.send( - request.http_method, - _url, - headers=combined_headers.items(), - params=request.query.items() if request.query else None, - json_body=request.json, - data=request.content, - ) - self._raise_if_expired_token(_buffered.status) - yield _BufferedResponseInfo(_buffered) - - def _raise_if_expired_token(self, status: int) -> None: - if ( - status == HTTPStatus.UNAUTHORIZED - and _PrivateNetworkInfo.get(self).account.credentials_format - == CredentialsFormats.OAUTH2 - ): - # Assume unauthorized because of token expiration. - raise exceptions.ExpiredAccessToken + # Assume unauthorized because of token expiration. + raise exceptions.ExpiredAccessToken + yield _AiohttpResponseInfo(_response) ### diff --git a/addon_service/common/urllib_fallback.py b/addon_service/common/urllib_fallback.py deleted file mode 100644 index 63bfef46..00000000 --- a/addon_service/common/urllib_fallback.py +++ /dev/null @@ -1,92 +0,0 @@ -"""fallback http sending for providers whose CDN challenges aiohttp requests - -Some providers (e.g. Mendeley, behind Cloudflare) answer aiohttp requests with a -"403 text/html" bot challenge, though the same request sent with stdlib `urllib` gets through. -""" - -import asyncio -import dataclasses -import json -import urllib.error -import urllib.parse -import urllib.request -from http import HTTPStatus -from typing import ( - Any, - Iterable, -) - - -__all__ = ("BufferedResponse", "is_bot_challenge", "send") - -USER_AGENT = "GravyValet/1.0" -_TIMEOUT_SECONDS = 30 - - -@dataclasses.dataclass -class BufferedResponse: - status: int - content_type: str - headers: list[tuple[str, str]] - body: bytes - charset: str = "utf-8" - - @property - def text(self) -> str: - return self.body.decode(self.charset) - - def json(self) -> Any: - return json.loads(self.text) - - -def is_bot_challenge(status: int, content_type: str | None) -> bool: - return status == HTTPStatus.FORBIDDEN and content_type == "text/html" - - -async def send( - method: str, - url: str, - *, - headers: Iterable[tuple[str, str]] = (), - params: Iterable[tuple[str, str]] | None = None, - json_body: Any = None, - data: str | bytes | dict | None = None, -) -> BufferedResponse: - return await asyncio.to_thread( - _send_blocking, str(method), url, headers, params, json_body, data - ) - - -def _send_blocking(method, url, headers, params, json_body, data) -> BufferedResponse: - _headers = dict(headers) - _headers["User-Agent"] = USER_AGENT - _body = None - if json_body is not None: - _body = json.dumps(json_body).encode() - _headers.setdefault("Content-Type", "application/json") - elif isinstance(data, dict): - _body = urllib.parse.urlencode(data).encode() - _headers.setdefault("Content-Type", "application/x-www-form-urlencoded") - elif isinstance(data, str): - _body = data.encode() - elif data is not None: - _body = data - if params: - _sep = "&" if urllib.parse.urlsplit(url).query else "?" - url = f"{url}{_sep}{urllib.parse.urlencode(list(params))}" - _request = urllib.request.Request(url, data=_body, method=method, headers=_headers) - try: - with urllib.request.urlopen(_request, timeout=_TIMEOUT_SECONDS) as _response: - return _buffer(_response, _response.status) - except urllib.error.HTTPError as _error: - return _buffer(_error, _error.code) - - -def _buffer(response, status: int) -> BufferedResponse: - return BufferedResponse( - status=status, - content_type=response.headers.get_content_type(), - headers=list(response.headers.items()), - body=response.read(), - charset=response.headers.get_content_charset() or "utf-8", - ) diff --git a/addon_service/oauth2/utils.py b/addon_service/oauth2/utils.py index 611fce45..aa605544 100644 --- a/addon_service/oauth2/utils.py +++ b/addon_service/oauth2/utils.py @@ -4,7 +4,6 @@ from secrets import token_urlsafe from typing import Iterable -from addon_service.common import urllib_fallback from addon_service.common.aiohttp_session import get_singleton_client_session from addon_toolkit.iri_utils import iri_with_query @@ -121,53 +120,19 @@ async def get_refreshed_access_token( async def _token_request( token_endpoint_url: str, request_body: dict[str, str] ) -> FreshTokenResult: - _status, _content_type, _payload = await _post_token_request( - token_endpoint_url, request_body - ) - if _content_type == "application/x-www-form-urlencoded": - response_data = dict(urllib.parse.parse_qsl(_payload)) - if expires := response_data.get("expires_in"): - response_data["expires_in"] = int(expires) - - return FreshTokenResult.from_token_response_json(response_data) - if not HTTPStatus(_status).is_success: - raise RuntimeError( - f"token request to {token_endpoint_url} failed: {_status} {str(_payload)[:200]}" - ) - # TODO: https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2 - return FreshTokenResult.from_token_response_json(_payload) - - -async def _post_token_request( - token_endpoint_url: str, request_body: dict[str, str] -) -> tuple[int, str, str | dict]: - """returns (status, content_type, payload), payload being text unless json""" _client = await get_singleton_client_session() - async with _client.post(token_endpoint_url, data=request_body) as _response: - if not urllib_fallback.is_bot_challenge( - _response.status, _response.content_type - ): - return ( - _response.status, - _response.content_type, - ( - await _response.json() - if _response.content_type == "application/json" - else await _response.text() - ), - ) - _buffered = await urllib_fallback.send( - "POST", token_endpoint_url, data=request_body - ) - return ( - _buffered.status, - _buffered.content_type, - ( - _buffered.json() - if _buffered.content_type == "application/json" - else _buffered.text - ), - ) + async with _client.post(token_endpoint_url, data=request_body) as _token_response: + if _token_response.content_type == "application/x-www-form-urlencoded": + response_text = await _token_response.text() + response_data = dict(urllib.parse.parse_qsl(response_text)) + if expires := response_data.get("expires_in"): + response_data["expires_in"] = int(expires) + + return FreshTokenResult.from_token_response_json(response_data) + if not HTTPStatus(_token_response.status).is_success: + raise RuntimeError(await _token_response.json()) + # TODO: https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2 + return FreshTokenResult.from_token_response_json(await _token_response.json()) def _parse_scope_param_value(scope_value: str | None) -> list[str] | None: diff --git a/addon_service/tests/test_oauth2_callback_view.py b/addon_service/tests/test_oauth2_callback_view.py index 4efe2f1f..e3d17396 100644 --- a/addon_service/tests/test_oauth2_callback_view.py +++ b/addon_service/tests/test_oauth2_callback_view.py @@ -1,8 +1,15 @@ from http import HTTPStatus +from unittest import IsolatedAsyncioTestCase from django.test import SimpleTestCase from django.urls import reverse +from addon_service.common.aiohttp_session import ( + USER_AGENT, + close_singleton_client_session, + get_singleton_client_session, +) + class TestOAuth2CallbackViewErrors(SimpleTestCase): def _get_error(self, **params): @@ -24,3 +31,12 @@ def test_error_params_are_not_reflected(self): self.assertTrue(_resp["Content-Type"].startswith("text/plain")) self.assertNotIn("