From a5d4b2d4c9e46bfa11c82f85fdc2bcc0b50ae681 Mon Sep 17 00:00:00 2001 From: Hsiaoming Yang Date: Wed, 25 Feb 2026 23:48:19 +0800 Subject: [PATCH 01/12] fix(jose): do not use header's jwk automatically --- authlib/jose/rfc7515/jws.py | 2 -- authlib/jose/rfc7516/jwe.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/authlib/jose/rfc7515/jws.py b/authlib/jose/rfc7515/jws.py index 65a7e973..d9f5cae4 100644 --- a/authlib/jose/rfc7515/jws.py +++ b/authlib/jose/rfc7515/jws.py @@ -269,8 +269,6 @@ def _prepare_algorithm_key(self, header, payload, key): algorithm = self.ALGORITHMS_REGISTRY[alg] if callable(key): key = key(header, payload) - elif key is None and "jwk" in header: - key = header["jwk"] key = algorithm.prepare_key(key) return algorithm, key diff --git a/authlib/jose/rfc7516/jwe.py b/authlib/jose/rfc7516/jwe.py index e58a7b7c..6393ad5f 100644 --- a/authlib/jose/rfc7516/jwe.py +++ b/authlib/jose/rfc7516/jwe.py @@ -754,6 +754,4 @@ def _validate_private_headers(self, header, alg): def prepare_key(alg, header, key): if callable(key): key = key(header, None) - elif key is None and "jwk" in header: - key = header["jwk"] return alg.prepare_key(key) From 48b345f29f6c459f11c6a40162b6c0b742ef2e22 Mon Sep 17 00:00:00 2001 From: Hsiaoming Yang Date: Thu, 26 Feb 2026 00:10:46 +0800 Subject: [PATCH 02/12] fix(jose): remove deprecated algorithm from default registry --- authlib/jose/rfc7515/jws.py | 8 ++++++-- authlib/jose/rfc7515/models.py | 1 + authlib/jose/rfc7516/jwe.py | 14 +++++++++++--- authlib/jose/rfc7516/models.py | 1 + authlib/jose/rfc7518/jwe_algs.py | 1 + authlib/jose/rfc7518/jws_algs.py | 1 + 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/authlib/jose/rfc7515/jws.py b/authlib/jose/rfc7515/jws.py index d9f5cae4..92e24ce5 100644 --- a/authlib/jose/rfc7515/jws.py +++ b/authlib/jose/rfc7515/jws.py @@ -261,12 +261,16 @@ def _prepare_algorithm_key(self, header, payload, key): raise MissingAlgorithmError() alg = header["alg"] - if self._algorithms is not None and alg not in self._algorithms: - raise UnsupportedAlgorithmError() if alg not in self.ALGORITHMS_REGISTRY: raise UnsupportedAlgorithmError() algorithm = self.ALGORITHMS_REGISTRY[alg] + if self._algorithms is None: + if algorithm.deprecated: + raise UnsupportedAlgorithmError() + elif alg not in self._algorithms: + raise UnsupportedAlgorithmError() + if callable(key): key = key(header, payload) key = algorithm.prepare_key(key) diff --git a/authlib/jose/rfc7515/models.py b/authlib/jose/rfc7515/models.py index d14fb641..b1261b42 100644 --- a/authlib/jose/rfc7515/models.py +++ b/authlib/jose/rfc7515/models.py @@ -5,6 +5,7 @@ class JWSAlgorithm: name = None description = None + deprecated = False algorithm_type = "JWS" algorithm_location = "alg" diff --git a/authlib/jose/rfc7516/jwe.py b/authlib/jose/rfc7516/jwe.py index 6393ad5f..3cfc9372 100644 --- a/authlib/jose/rfc7516/jwe.py +++ b/authlib/jose/rfc7516/jwe.py @@ -697,11 +697,19 @@ def get_header_alg(self, header): raise MissingAlgorithmError() alg = header["alg"] - if self._algorithms is not None and alg not in self._algorithms: - raise UnsupportedAlgorithmError() if alg not in self.ALG_REGISTRY: raise UnsupportedAlgorithmError() - return self.ALG_REGISTRY[alg] + + instance = self.ALG_REGISTRY[alg] + + # use all ALG_REGISTRY algorithms + if self._algorithms is None: + # do not use deprecated algorithms + if instance.deprecated: + raise UnsupportedAlgorithmError() + elif alg not in self._algorithms: + raise UnsupportedAlgorithmError() + return instance def get_header_enc(self, header): if "enc" not in header: diff --git a/authlib/jose/rfc7516/models.py b/authlib/jose/rfc7516/models.py index 2bcca8c8..ce98257f 100644 --- a/authlib/jose/rfc7516/models.py +++ b/authlib/jose/rfc7516/models.py @@ -9,6 +9,7 @@ class JWEAlgorithmBase(metaclass=ABCMeta): # noqa: B024 name = None description = None + deprecated = False algorithm_type = "JWE" algorithm_location = "alg" diff --git a/authlib/jose/rfc7518/jwe_algs.py b/authlib/jose/rfc7518/jwe_algs.py index e22718a0..2c73a654 100644 --- a/authlib/jose/rfc7518/jwe_algs.py +++ b/authlib/jose/rfc7518/jwe_algs.py @@ -52,6 +52,7 @@ class RSAAlgorithm(JWEAlgorithm): def __init__(self, name, description, pad_fn): self.name = name + self.deprecated = name == "RSA1_5" self.description = description self.padding = pad_fn diff --git a/authlib/jose/rfc7518/jws_algs.py b/authlib/jose/rfc7518/jws_algs.py index 3f97530a..c9e95ec5 100644 --- a/authlib/jose/rfc7518/jws_algs.py +++ b/authlib/jose/rfc7518/jws_algs.py @@ -27,6 +27,7 @@ class NoneAlgorithm(JWSAlgorithm): name = "none" description = "No digital signature or MAC performed" + deprecated = True def prepare_key(self, raw_data): return None From 5be3c518794b7322375bae2bf1871713d9b5c2fb Mon Sep 17 00:00:00 2001 From: Hsiaoming Yang Date: Thu, 26 Feb 2026 00:11:18 +0800 Subject: [PATCH 03/12] fix(jose): add ES256K into default jwt algorithms --- authlib/jose/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/authlib/jose/__init__.py b/authlib/jose/__init__.py index 165ef9a1..6670549f 100644 --- a/authlib/jose/__init__.py +++ b/authlib/jose/__init__.py @@ -55,6 +55,7 @@ "RS384", "RS512", "ES256", + "ES256K", "ES384", "ES512", "PS256", From 1b0a1d988842bff7347c4ec0a70e45c3ba55504e Mon Sep 17 00:00:00 2001 From: Hsiaoming Yang Date: Thu, 26 Feb 2026 00:21:16 +0800 Subject: [PATCH 04/12] fix(jose): generate random cek when cek length doesn't match --- authlib/jose/rfc7518/jwe_algs.py | 13 ++++++------- tests/jose/test_chacha20.py | 3 ++- tests/jose/test_jwe.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/authlib/jose/rfc7518/jwe_algs.py b/authlib/jose/rfc7518/jwe_algs.py index 2c73a654..778cc478 100644 --- a/authlib/jose/rfc7518/jwe_algs.py +++ b/authlib/jose/rfc7518/jwe_algs.py @@ -1,4 +1,4 @@ -import os +import secrets import struct from cryptography.hazmat.backends import default_backend @@ -41,7 +41,7 @@ def wrap(self, enc_alg, headers, key, preset=None): def unwrap(self, enc_alg, ek, headers, key): cek = key.get_op_key("decrypt") if len(cek) * 8 != enc_alg.CEK_SIZE: - raise ValueError('Invalid "cek" length') + cek = secrets.token_bytes(enc_alg.CEK_SIZE // 8) return cek @@ -76,11 +76,10 @@ def wrap(self, enc_alg, headers, key, preset=None): return {"ek": ek, "cek": cek} def unwrap(self, enc_alg, ek, headers, key): - # it will raise ValueError if failed op_key = key.get_op_key("unwrapKey") cek = op_key.decrypt(ek, self.padding) if len(cek) * 8 != enc_alg.CEK_SIZE: - raise ValueError('Invalid "cek" length') + cek = secrets.token_bytes(enc_alg.CEK_SIZE // 8) return cek @@ -119,7 +118,7 @@ def unwrap(self, enc_alg, ek, headers, key): self._check_key(op_key) cek = aes_key_unwrap(op_key, ek, default_backend()) if len(cek) * 8 != enc_alg.CEK_SIZE: - raise ValueError('Invalid "cek" length') + cek = secrets.token_bytes(enc_alg.CEK_SIZE // 8) return cek @@ -155,7 +154,7 @@ def wrap(self, enc_alg, headers, key, preset=None): #: The "iv" (initialization vector) Header Parameter value is the #: base64url-encoded representation of the 96-bit IV value iv_size = 96 - iv = os.urandom(iv_size // 8) + iv = secrets.token_bytes(iv_size // 8) cipher = Cipher(AES(op_key), GCM(iv), backend=default_backend()) enc = cipher.encryptor() @@ -186,7 +185,7 @@ def unwrap(self, enc_alg, ek, headers, key): d = cipher.decryptor() cek = d.update(ek) + d.finalize() if len(cek) * 8 != enc_alg.CEK_SIZE: - raise ValueError('Invalid "cek" length') + cek = secrets.token_bytes(enc_alg.CEK_SIZE // 8) return cek diff --git a/tests/jose/test_chacha20.py b/tests/jose/test_chacha20.py index 5f39f359..aea4f110 100644 --- a/tests/jose/test_chacha20.py +++ b/tests/jose/test_chacha20.py @@ -1,4 +1,5 @@ import pytest +from cryptography.exceptions import InvalidTag from authlib.jose import JsonWebEncryption from authlib.jose import OctKey @@ -16,7 +17,7 @@ def test_dir_alg_c20p(): assert rv["payload"] == b"hello" key2 = OctKey.generate_key(128, is_private=True) - with pytest.raises(ValueError): + with pytest.raises(InvalidTag): jwe.deserialize_compact(data, key2) with pytest.raises(ValueError): diff --git a/tests/jose/test_jwe.py b/tests/jose/test_jwe.py index 2f476ca3..a59c9ad2 100644 --- a/tests/jose/test_jwe.py +++ b/tests/jose/test_jwe.py @@ -1143,7 +1143,7 @@ def test_dir_alg(): assert rv["payload"] == b"hello" key2 = OctKey.generate_key(256, is_private=True) - with pytest.raises(ValueError): + with pytest.raises(InvalidTag): jwe.deserialize_compact(data, key2) with pytest.raises(ValueError): From b9bb2b25bf8b7e01512d847a95c1749646eaa72b Mon Sep 17 00:00:00 2001 From: Hsiaoming Yang Date: Sun, 1 Mar 2026 00:30:33 +0900 Subject: [PATCH 05/12] fix(oidc): fail close at validating c_hash and at_hash --- authlib/oidc/core/claims.py | 4 ++-- tests/core/test_oidc/test_core.py | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/authlib/oidc/core/claims.py b/authlib/oidc/core/claims.py index dc707730..9b1186b3 100644 --- a/authlib/oidc/core/claims.py +++ b/authlib/oidc/core/claims.py @@ -303,6 +303,6 @@ def get_claim_cls_by_response_type(response_type): def _verify_hash(signature, s, alg): hash_value = create_half_hash(s, alg) - if not hash_value: - return True + if hash_value is None: + return False return hmac.compare_digest(hash_value, to_bytes(signature)) diff --git a/tests/core/test_oidc/test_core.py b/tests/core/test_oidc/test_core.py index 30fca3c5..0c0d6f01 100644 --- a/tests/core/test_oidc/test_core.py +++ b/tests/core/test_oidc/test_core.py @@ -99,9 +99,10 @@ def test_validate_at_hash(): ) claims.params = {"access_token": "a"} - # invalid alg won't raise + # invalid alg will raise too claims.header = {"alg": "HS222"} - claims.validate(1000) + with pytest.raises(InvalidClaimError): + claims.validate(1000) claims.header = {"alg": "HS256"} with pytest.raises(InvalidClaimError): @@ -143,10 +144,11 @@ def test_hybrid_id_token(): with pytest.raises(MissingClaimError): claims.validate(1000) - # invalid alg won't raise + # invalid alg will raise too claims.header = {"alg": "HS222"} claims["c_hash"] = "a" - claims.validate(1000) + with pytest.raises(InvalidClaimError): + claims.validate(1000) claims.header = {"alg": "HS256"} with pytest.raises(InvalidClaimError): From 9266eaa2227ad7e21dc731b2a4a01909aabd934b Mon Sep 17 00:00:00 2001 From: Hsiaoming Yang Date: Mon, 2 Mar 2026 16:42:53 +0900 Subject: [PATCH 06/12] chore: release 1.6.9 --- authlib/consts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authlib/consts.py b/authlib/consts.py index 69437da4..ed67bccf 100644 --- a/authlib/consts.py +++ b/authlib/consts.py @@ -1,5 +1,5 @@ name = "Authlib" -version = "1.6.8" +version = "1.6.9" author = "Hsiaoming Yang " homepage = "https://authlib.org" default_user_agent = f"{name}/{version} (+{homepage})" From 3be08468201a7766a93012ce149ea12822cab096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Sun, 29 Mar 2026 09:21:42 +0200 Subject: [PATCH 07/12] fix: redirecting to unvalidated redirect_uri on UnsupportedResponseTypeError --- .../oauth2/rfc6749/authorization_server.py | 13 +++++- docs/changelog.rst | 31 +++++++++++++ .../test_authorization_code_grant.py | 44 +++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/authlib/oauth2/rfc6749/authorization_server.py b/authlib/oauth2/rfc6749/authorization_server.py index 928251dc..c484aa6c 100644 --- a/authlib/oauth2/rfc6749/authorization_server.py +++ b/authlib/oauth2/rfc6749/authorization_server.py @@ -241,10 +241,21 @@ def get_authorization_grant(self, request): if grant_cls.check_authorization_endpoint(request): return _create_grant(grant_cls, extensions, request, self) + # Per RFC 6749 §4.1.2.1, only redirect with the error if the client + # exists and the redirect_uri has been validated against it. + redirect_uri = None + if client_id := request.payload.client_id: + if client := self.query_client(client_id): + if requested_uri := request.payload.redirect_uri: + if client.check_redirect_uri(requested_uri): + redirect_uri = requested_uri + else: + redirect_uri = client.get_default_redirect_uri() + raise UnsupportedResponseTypeError( f"The response type '{request.payload.response_type}' is not supported by the server.", request.payload.response_type, - redirect_uri=request.payload.redirect_uri, + redirect_uri=redirect_uri, ) def get_consent_grant(self, request=None, end_user=None): diff --git a/docs/changelog.rst b/docs/changelog.rst index 1e557f58..c35a911b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,37 @@ Changelog Here you can see the full list of changes between each Authlib release. +Version 1.6.10 +-------------- + +**Unreleased** + +- Fix redirecting to unvalidated ``redirect_uri`` on ``UnsupportedResponseTypeError``. + +Version 1.6.9 +------------- + +**Released on Mar 2, 2026** + +- Not using header's ``jwk`` automatically. +- Add ``ES256K`` into default jwt algorithms. +- Remove deprecated algorithm from default registry. +- Generate random ``cek`` when ``cek`` length doesn't match. + +Version 1.6.8 +------------- + +**Released on Feb 17, 2026** + +- Add ``EdDSA`` to default ``jwt`` instance. + +Version 1.6.7 +------------- + +**Released on Feb 6, 2026** + +- Set supported algorithms for the default ``jwt`` instance. + Version 1.6.6 ------------- diff --git a/tests/flask/test_oauth2/test_authorization_code_grant.py b/tests/flask/test_oauth2/test_authorization_code_grant.py index f8d77fc9..6d437e2c 100644 --- a/tests/flask/test_oauth2/test_authorization_code_grant.py +++ b/tests/flask/test_oauth2/test_authorization_code_grant.py @@ -352,3 +352,47 @@ def test_token_generator(app, test_client, client, server): resp = json.loads(rv.data) assert "access_token" in resp assert "c-authorization_code.1." in resp["access_token"] + + +def test_missing_scope_empty_default(test_client, client, monkeypatch): + """When client.get_allowed_scope() returns empty string for missing scope, + the authorization should proceed without a scope. + """ + + def get_allowed_scope_empty(scope): + if scope is None: + return "" + return scope + + monkeypatch.setattr(client, "get_allowed_scope", get_allowed_scope_empty) + + rv = test_client.post(authorize_url, data={"user_id": "1"}) + assert "code=" in rv.location + + params = dict(url_decode(urlparse.urlparse(rv.location).query)) + code = params["code"] + headers = create_basic_header("client-id", "client-secret") + rv = test_client.post( + "/oauth/token", + data={ + "grant_type": "authorization_code", + "code": code, + }, + headers=headers, + ) + resp = json.loads(rv.data) + assert "access_token" in resp + assert resp.get("scope", "") == "" + + +def test_unsupported_response_type_does_not_redirect(test_client): + """Regression test for open redirect via unsupported response_type.""" + url = ( + "/oauth/authorize" + "?response_type=totally-unsupported" + "&redirect_uri=https%3A%2F%2Fevil.example%2Flanding" + "&state=s1" + ) + rv = test_client.get(url) + assert rv.status_code == 400 + assert rv.headers.get("Location") is None From ef09aebbba4439dedb22bd15777d1b3458b6f0ab Mon Sep 17 00:00:00 2001 From: Hsiaoming Yang Date: Mon, 13 Apr 2026 22:29:14 +0900 Subject: [PATCH 08/12] chore: release 1.6.10 --- authlib/consts.py | 2 +- docs/changelog.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/authlib/consts.py b/authlib/consts.py index ed67bccf..1fc26196 100644 --- a/authlib/consts.py +++ b/authlib/consts.py @@ -1,5 +1,5 @@ name = "Authlib" -version = "1.6.9" +version = "1.6.10" author = "Hsiaoming Yang " homepage = "https://authlib.org" default_user_agent = f"{name}/{version} (+{homepage})" diff --git a/docs/changelog.rst b/docs/changelog.rst index c35a911b..2cdab361 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,7 +9,7 @@ Here you can see the full list of changes between each Authlib release. Version 1.6.10 -------------- -**Unreleased** +**Released on Apr 13, 2026** - Fix redirecting to unvalidated ``redirect_uri`` on ``UnsupportedResponseTypeError``. From 401a7709c3fe43bce1b2105d16a475b688faa788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Wed, 15 Apr 2026 09:42:04 +0200 Subject: [PATCH 09/12] fix: CSRF issue with starlette client --- authlib/integrations/starlette_client/apps.py | 17 ++---- .../starlette_client/integration.py | 14 ++++- docs/changelog.rst | 8 +++ .../test_starlette/test_oauth_client.py | 60 +++++++++++++++++++ 4 files changed, 85 insertions(+), 14 deletions(-) diff --git a/authlib/integrations/starlette_client/apps.py b/authlib/integrations/starlette_client/apps.py index b97143cf..c69e838d 100644 --- a/authlib/integrations/starlette_client/apps.py +++ b/authlib/integrations/starlette_client/apps.py @@ -14,11 +14,7 @@ class StarletteAppMixin: async def save_authorize_data(self, request, **kwargs): state = kwargs.pop("state", None) if state: - if self.framework.cache: - session = None - else: - session = request.session - await self.framework.set_state_data(session, state, kwargs) + await self.framework.set_state_data(request.session, state, kwargs) else: raise RuntimeError("Missing state value") @@ -80,13 +76,10 @@ async def authorize_access_token(self, request, **kwargs): "state": form.get("state"), } - if self.framework.cache: - session = None - else: - session = request.session - - state_data = await self.framework.get_state_data(session, params.get("state")) - await self.framework.clear_state_data(session, params.get("state")) + state_data = await self.framework.get_state_data( + request.session, params.get("state") + ) + await self.framework.clear_state_data(request.session, params.get("state")) params = self._format_state_params(state_data, params) claims_options = kwargs.pop("claims_options", None) diff --git a/authlib/integrations/starlette_client/integration.py b/authlib/integrations/starlette_client/integration.py index 25b7fdbc..db3639b6 100644 --- a/authlib/integrations/starlette_client/integration.py +++ b/authlib/integrations/starlette_client/integration.py @@ -22,6 +22,10 @@ async def get_state_data( ) -> dict[str, Any]: key = f"_state_{self.name}_{state}" if self.cache: + # require a session-bound marker to prove the callback originates + # from the user-agent that started the flow (RFC 6749 §10.12) + if session is None or session.get(key) is None: + return None value = await self._get_cache_data(key) elif session is not None: value = session.get(key) @@ -37,21 +41,27 @@ async def set_state_data( ): key_prefix = f"_state_{self.name}_" key = f"{key_prefix}{state}" + now = time.time() if self.cache: await self.cache.set(key, json.dumps({"data": data}), self.expires_in) + if session is not None: + # clear old state data to avoid session size growing + for old_key in list(session.keys()): + if old_key.startswith(key_prefix): + session.pop(old_key) + session[key] = {"exp": now + self.expires_in} elif session is not None: # clear old state data to avoid session size growing for old_key in list(session.keys()): if old_key.startswith(key_prefix): session.pop(old_key) - now = time.time() session[key] = {"data": data, "exp": now + self.expires_in} async def clear_state_data(self, session: Optional[dict[str, Any]], state: str): key = f"_state_{self.name}_{state}" if self.cache: await self.cache.delete(key) - elif session is not None: + if session is not None: session.pop(key, None) self._clear_session_state(session) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2cdab361..34ffc38f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,14 @@ Changelog Here you can see the full list of changes between each Authlib release. +Version 1.6.11 +-------------- + +**Released on Apr 15, 2026** + +- Fix CSRF vulnerability in the Starlette OAuth client when a ``cache`` is + configured. + Version 1.6.10 -------------- diff --git a/tests/clients/test_starlette/test_oauth_client.py b/tests/clients/test_starlette/test_oauth_client.py index 74729710..5f5a909a 100644 --- a/tests/clients/test_starlette/test_oauth_client.py +++ b/tests/clients/test_starlette/test_oauth_client.py @@ -118,6 +118,66 @@ async def test_oauth2_authorize(): assert token["access_token"] == "a" +class _FakeAsyncCache: + """Minimal async cache implementing the authlib framework cache protocol.""" + + def __init__(self): + self.store = {} + + async def get(self, key): + return self.store.get(key) + + async def set(self, key, value, expires=None): + self.store[key] = value + + async def delete(self, key): + self.store.pop(key, None) + + +@pytest.mark.asyncio +async def test_oauth2_authorize_csrf_with_cache(): + """When a cache is configured, the state must still be bound to the + session that initiated the flow. Otherwise an attacker can start an + authorization request, stop before the callback, and trick a victim into + completing the flow — logging the victim into the attacker's account + (RFC 6749 §10.12).""" + transport = ASGITransport( + AsyncPathMapDispatch({"/token": {"body": get_bearer_token()}}) + ) + oauth = OAuth(cache=_FakeAsyncCache()) + client = oauth.register( + "dev", + client_id="dev", + client_secret="dev", + api_base_url="https://resource.test/api", + access_token_url="https://provider.test/token", + authorize_url="https://provider.test/authorize", + client_kwargs={ + "transport": transport, + }, + ) + + # Attacker initiates an auth flow from their own session. + attacker_req = Request({"type": "http", "session": {}}) + resp = await client.authorize_redirect(attacker_req, "https://client.test/callback") + assert resp.status_code == 302 + url = resp.headers.get("Location") + state = dict(url_decode(urlparse.urlparse(url).query))["state"] + + # Victim is tricked into hitting the callback URL. The victim's browser + # carries a *different* session — they never initiated this flow. + victim_req = Request( + { + "type": "http", + "path": "/", + "query_string": f"code=a&state={state}".encode(), + "session": {}, + } + ) + with pytest.raises(OAuthError): + await client.authorize_access_token(victim_req) + + @pytest.mark.asyncio async def test_oauth2_authorize_access_denied(): oauth = OAuth() From 0dc0e5b4dc84f155319518a3732113af6fa47525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Thu, 16 Apr 2026 09:17:54 +0200 Subject: [PATCH 10/12] chore: bump to 1.6.11 --- authlib/consts.py | 2 +- docs/changelog.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/authlib/consts.py b/authlib/consts.py index 1fc26196..38d259d3 100644 --- a/authlib/consts.py +++ b/authlib/consts.py @@ -1,5 +1,5 @@ name = "Authlib" -version = "1.6.10" +version = "1.6.11" author = "Hsiaoming Yang " homepage = "https://authlib.org" default_user_agent = f"{name}/{version} (+{homepage})" diff --git a/docs/changelog.rst b/docs/changelog.rst index 34ffc38f..799a6502 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,7 +9,7 @@ Here you can see the full list of changes between each Authlib release. Version 1.6.11 -------------- -**Released on Apr 15, 2026** +**Released on Apr 16, 2026** - Fix CSRF vulnerability in the Starlette OAuth client when a ``cache`` is configured. From 9babc131e13b018a267ae78747cba7caa6dfb7d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Sat, 2 May 2026 17:56:03 +0200 Subject: [PATCH 11/12] fix: redirecting to unvalidated redirect_uri on InvalidScopeError in OIDC grants --- authlib/oidc/core/grants/hybrid.py | 15 ++++++++------- authlib/oidc/core/grants/implicit.py | 4 ++-- docs/changelog.rst | 8 ++++++++ .../test_oauth2/test_openid_hybrid_grant.py | 17 +++++++++++++++++ .../test_oauth2/test_openid_implict_grant.py | 17 +++++++++++++++++ 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/authlib/oidc/core/grants/hybrid.py b/authlib/oidc/core/grants/hybrid.py index 8c373525..c3605eb8 100644 --- a/authlib/oidc/core/grants/hybrid.py +++ b/authlib/oidc/core/grants/hybrid.py @@ -49,19 +49,20 @@ def save_authorization_code(self, code, request): raise NotImplementedError() def validate_authorization_request(self): - if not is_openid_scope(self.request.payload.scope): - raise InvalidScopeError( - "Missing 'openid' scope", - redirect_uri=self.request.payload.redirect_uri, - redirect_fragment=True, - ) self.register_hook( "after_validate_authorization_request_payload", lambda grant, redirect_uri: validate_nonce( grant.request, grant.exists_nonce, required=True ), ) - return validate_code_authorization_request(self) + redirect_uri = validate_code_authorization_request(self) + if not is_openid_scope(self.request.payload.scope): + raise InvalidScopeError( + "Missing 'openid' scope", + redirect_uri=redirect_uri, + redirect_fragment=True, + ) + return redirect_uri def create_granted_params(self, grant_user): self.request.user = grant_user diff --git a/authlib/oidc/core/grants/implicit.py b/authlib/oidc/core/grants/implicit.py index fc76371f..aacdf865 100644 --- a/authlib/oidc/core/grants/implicit.py +++ b/authlib/oidc/core/grants/implicit.py @@ -82,13 +82,13 @@ def get_audiences(self, request): return [client.get_client_id()] def validate_authorization_request(self): + redirect_uri = super().validate_authorization_request() if not is_openid_scope(self.request.payload.scope): raise InvalidScopeError( "Missing 'openid' scope", - redirect_uri=self.request.payload.redirect_uri, + redirect_uri=redirect_uri, redirect_fragment=True, ) - redirect_uri = super().validate_authorization_request() try: validate_nonce(self.request, self.exists_nonce, required=True) except OAuth2Error as error: diff --git a/docs/changelog.rst b/docs/changelog.rst index 799a6502..46f7dced 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,14 @@ Changelog Here you can see the full list of changes between each Authlib release. +Version 1.6.12 +-------------- + +**Unreleased** + +- Fix redirecting to unvalidated ``redirect_uri`` on ``InvalidScopeError`` + in ``OpenIDImplicitGrant`` and ``OpenIDHybridGrant``. + Version 1.6.11 -------------- diff --git a/tests/flask/test_oauth2/test_openid_hybrid_grant.py b/tests/flask/test_oauth2/test_openid_hybrid_grant.py index 5aeb3726..15ac1f6c 100644 --- a/tests/flask/test_oauth2/test_openid_hybrid_grant.py +++ b/tests/flask/test_oauth2/test_openid_hybrid_grant.py @@ -160,6 +160,23 @@ def test_invalid_scope(test_client): assert "error=invalid_scope" in rv.location +def test_missing_openid_in_scope_does_not_redirect_to_unregistered_uri(test_client): + """An unregistered redirect_uri must not be followed even when openid scope is missing.""" + rv = test_client.post( + "/oauth/authorize", + data={ + "client_id": "client-id", + "response_type": "code id_token", + "state": "s", + "nonce": "n", + "scope": "profile", + "redirect_uri": "https://evil.example.com/phish", + }, + ) + assert rv.status_code == 400 + assert rv.headers.get("Location") is None + + def test_access_denied(test_client): rv = test_client.post( "/oauth/authorize", diff --git a/tests/flask/test_oauth2/test_openid_implict_grant.py b/tests/flask/test_oauth2/test_openid_implict_grant.py index 1a24d51a..3806466b 100644 --- a/tests/flask/test_oauth2/test_openid_implict_grant.py +++ b/tests/flask/test_oauth2/test_openid_implict_grant.py @@ -107,6 +107,23 @@ def test_missing_openid_in_scope(test_client): assert "error=invalid_scope" in rv.location +def test_missing_openid_in_scope_does_not_redirect_to_unregistered_uri(test_client): + """An unregistered redirect_uri must not be followed even when openid scope is missing.""" + rv = test_client.post( + "/oauth/authorize", + data={ + "response_type": "id_token token", + "client_id": "client-id", + "scope": "profile", + "state": "s", + "nonce": "n", + "redirect_uri": "https://evil.example.com/phish", + }, + ) + assert rv.status_code == 400 + assert rv.headers.get("Location") is None + + def test_denied(test_client): rv = test_client.post( "/oauth/authorize", From e46e515b3a87ea63ab0606b248d75f69d83a2391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Mon, 4 May 2026 10:07:39 +0200 Subject: [PATCH 12/12] chore: bump to 1.6.12 --- authlib/consts.py | 2 +- docs/changelog.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/authlib/consts.py b/authlib/consts.py index 38d259d3..de771c1c 100644 --- a/authlib/consts.py +++ b/authlib/consts.py @@ -1,5 +1,5 @@ name = "Authlib" -version = "1.6.11" +version = "1.6.12" author = "Hsiaoming Yang " homepage = "https://authlib.org" default_user_agent = f"{name}/{version} (+{homepage})" diff --git a/docs/changelog.rst b/docs/changelog.rst index 46f7dced..e6cc6ee4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,7 +9,7 @@ Here you can see the full list of changes between each Authlib release. Version 1.6.12 -------------- -**Unreleased** +**Released on may 4, 2026** - Fix redirecting to unvalidated ``redirect_uri`` on ``InvalidScopeError`` in ``OpenIDImplicitGrant`` and ``OpenIDHybridGrant``.