Fix Logto callback SSL error: extend LOGTO_SKIP_SSL_VERIFY patch to PyJWKClient (JWKS/urllib)

The 'Fail to fetch data from the url' callback error came from PyJWT's
PyJWKClient.fetch_data() using urllib to retrieve the JWKS, which is not
covered by the existing aiohttp.ClientSession SSL monkey-patch.

Extend _apply_logto_ssl_patch() to also replace PyJWKClient inside
logto.OidcCore with a subclass that injects the non-verifying ssl.SSLContext
via the ssl_context constructor parameter, ensuring both the OIDC discovery/
token requests (aiohttp) and ID-token JWKS verification (urllib) honour
LOGTO_SKIP_SSL_VERIFY=True.

Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/14676b1a-3421-4839-9ba3-8229a3e5adf1

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-30 17:15:58 +00:00
parent 8d75f2e045
commit 3a5b75e964
2 changed files with 140 additions and 9 deletions
+91
View File
@@ -8,6 +8,7 @@ These tests exercise:
- /api/v1/auth/me authenticated and unauthenticated
- /api/v1/auth/sign-in Logto not configured → 503
- /api/v1/auth/sign-out always clears the session cookie
- SSL bypass patching (_apply_logto_ssl_patch)
All tests use the in-memory SQLite fixture from conftest.py.
Logto SDK calls are mocked so no live Logto instance is needed.
@@ -329,3 +330,93 @@ class TestStaticAssetBypass:
res = client.get("/dashboard", follow_redirects=False)
assert res.status_code == 302
assert res.headers["location"].startswith("/login")
# ── SSL bypass patch ──────────────────────────────────────────────────────────
class TestApplyLogtoSslPatch:
"""_apply_logto_ssl_patch should extend both the aiohttp and PyJWKClient patches."""
def test_no_patch_when_ssl_verify_enabled(self):
"""When LOGTO_SKIP_SSL_VERIFY is False the function must not modify aiohttp."""
import aiohttp
original = aiohttp.ClientSession
mock_settings = MagicMock()
mock_settings.LOGTO_SKIP_SSL_VERIFY = False
with patch("app.core.logto.settings", mock_settings):
from app.core.logto import _apply_logto_ssl_patch
_apply_logto_ssl_patch()
assert aiohttp.ClientSession is original
def test_aiohttp_patched_when_ssl_skip_enabled(self):
"""When LOGTO_SKIP_SSL_VERIFY is True the aiohttp.ClientSession must be replaced."""
import aiohttp
original = aiohttp.ClientSession
mock_settings = MagicMock()
mock_settings.LOGTO_SKIP_SSL_VERIFY = True
with patch("app.core.logto.settings", mock_settings):
from app.core.logto import _apply_logto_ssl_patch
_apply_logto_ssl_patch()
try:
assert aiohttp.ClientSession is not original
finally:
# Restore so later tests are not affected.
aiohttp.ClientSession = original
def test_pyjwkclient_patched_when_ssl_skip_enabled(self):
"""When LOGTO_SKIP_SSL_VERIFY is True, PyJWKClient in logto.OidcCore must be
replaced with a subclass that injects a non-verifying ssl_context."""
import logto.OidcCore as _oidc_module
from jwt import PyJWKClient
original_pyjwkclient = _oidc_module.PyJWKClient
mock_settings = MagicMock()
mock_settings.LOGTO_SKIP_SSL_VERIFY = True
with patch("app.core.logto.settings", mock_settings):
from app.core.logto import _apply_logto_ssl_patch
_apply_logto_ssl_patch()
try:
patched = _oidc_module.PyJWKClient
assert patched is not PyJWKClient, "PyJWKClient should be replaced"
assert issubclass(patched, PyJWKClient), "Replacement must subclass PyJWKClient"
finally:
_oidc_module.PyJWKClient = original_pyjwkclient
def test_pyjwkclient_patch_injects_ssl_context(self):
"""The patched PyJWKClient must pass ssl_context to its parent when constructed."""
import ssl
import logto.OidcCore as _oidc_module
original_pyjwkclient = _oidc_module.PyJWKClient
mock_settings = MagicMock()
mock_settings.LOGTO_SKIP_SSL_VERIFY = True
with patch("app.core.logto.settings", mock_settings):
from app.core.logto import _apply_logto_ssl_patch
_apply_logto_ssl_patch()
try:
instance = _oidc_module.PyJWKClient("https://example.com/.well-known/jwks.json")
assert instance.ssl_context is not None
assert isinstance(instance.ssl_context, ssl.SSLContext)
assert instance.ssl_context.verify_mode == ssl.CERT_NONE
finally:
_oidc_module.PyJWKClient = original_pyjwkclient