Merge pull request #89 from christianlouis/copilot/fix-logto-callback-error

Fix Logto callback failure: extend LOGTO_SKIP_SSL_VERIFY to cover JWKS fetch (PyJWKClient/urllib)
This commit is contained in:
Christian Krakau-Louis
2026-03-30 21:42:23 +02:00
committed by GitHub
2 changed files with 140 additions and 9 deletions
+49 -9
View File
@@ -45,17 +45,31 @@ _SESSION_MAX_AGE = 86_400 # 24 hours
def _apply_logto_ssl_patch() -> None:
"""
If ``LOGTO_SKIP_SSL_VERIFY`` is ``True``, monkey-patch ``aiohttp.ClientSession``
so that every session created by the Logto SDK uses a non-verifying SSL connector.
If ``LOGTO_SKIP_SSL_VERIFY`` is ``True``, monkey-patch both
``aiohttp.ClientSession`` and the ``PyJWKClient`` used by the Logto SDK so
that every connection to the Logto OIDC endpoint skips SSL certificate
verification.
The Logto SDK creates its own ``aiohttp.ClientSession`` objects internally and
provides no mechanism to inject an SSL context. Replacing the class at module
level is the only way to propagate the setting without forking the SDK.
Two patches are applied:
**Scope note:** ``aiohttp`` is not used anywhere else in this application only
the Logto SDK pulls it in. If additional code in this repository starts using
``aiohttp`` directly, review whether those connections should also skip
verification before enabling this setting.
1. **aiohttp.ClientSession** The Logto SDK creates its own
``aiohttp.ClientSession`` objects internally (for the OIDC discovery
document and token-endpoint requests) and provides no mechanism to
inject an SSL context. Replacing the class at module level is the
only way to propagate the setting without forking the SDK.
2. **PyJWKClient** inside ``logto.OidcCore`` The Logto SDK uses
``PyJWKClient`` (from PyJWT) to fetch and verify the JWKS for
ID-token signature validation. ``PyJWKClient`` uses ``urllib``
internally, *not* ``aiohttp``, so the first patch does not cover it.
We replace the ``PyJWKClient`` reference in the ``logto.OidcCore``
module so that every ``OidcCore`` instance gets a client that passes
the non-verifying SSL context to ``urllib``.
**Scope note:** ``aiohttp`` is not used anywhere else in this application
only the Logto SDK pulls it in. If additional code in this repository
starts using ``aiohttp`` directly, review whether those connections should
also skip verification before enabling this setting.
.. warning::
Disabling SSL verification removes protection against man-in-the-middle
@@ -76,6 +90,9 @@ def _apply_logto_ssl_patch() -> None:
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
# ── Patch 1: aiohttp.ClientSession ───────────────────────────────────────
# Covers OIDC discovery-document and token-endpoint requests.
_OriginalClientSession = aiohttp.ClientSession
class _NoVerifyClientSession(_OriginalClientSession): # type: ignore[misc]
@@ -89,6 +106,29 @@ def _apply_logto_ssl_patch() -> None:
aiohttp.ClientSession = _NoVerifyClientSession # type: ignore[assignment]
# ── Patch 2: PyJWKClient inside logto.OidcCore ────────────────────────────
# Covers JWKS fetching for ID-token signature verification.
# PyJWKClient uses urllib internally, so Patch 1 does not cover it.
try:
import logto.OidcCore as _oidc_module # noqa: PLC0415
from jwt import PyJWKClient as _OrigPyJWKClient # noqa: PLC0415
class _NoVerifyPyJWKClient(_OrigPyJWKClient): # type: ignore[misc]
"""``PyJWKClient`` subclass that injects a non-verifying SSL context."""
def __init__(self, *args, **kwargs) -> None: # type: ignore[override]
kwargs.setdefault("ssl_context", ssl_ctx)
super().__init__(*args, **kwargs)
_oidc_module.PyJWKClient = _NoVerifyPyJWKClient # type: ignore[attr-defined]
except Exception as _exc: # pylint: disable=broad-exception-caught
logger.warning(
"Failed to patch PyJWKClient for LOGTO_SKIP_SSL_VERIFY: %s. "
"JWKS fetching will still verify SSL certificates, which may cause "
"ID-token verification to fail when using a self-signed certificate.",
_exc,
)
_apply_logto_ssl_patch()
+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