diff --git a/authlib/consts.py b/authlib/consts.py index 69437da4..de771c1c 100644 --- a/authlib/consts.py +++ b/authlib/consts.py @@ -1,5 +1,5 @@ name = "Authlib" -version = "1.6.8" +version = "1.6.12" author = "Hsiaoming Yang " homepage = "https://authlib.org" default_user_agent = f"{name}/{version} (+{homepage})" 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/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", diff --git a/authlib/jose/rfc7515/jws.py b/authlib/jose/rfc7515/jws.py index 65a7e973..92e24ce5 100644 --- a/authlib/jose/rfc7515/jws.py +++ b/authlib/jose/rfc7515/jws.py @@ -261,16 +261,18 @@ 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) - elif key is None and "jwk" in header: - key = header["jwk"] key = algorithm.prepare_key(key) return algorithm, 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 e58a7b7c..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: @@ -754,6 +762,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) 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..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 @@ -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 @@ -75,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 @@ -118,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 @@ -154,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() @@ -185,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/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 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/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/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 1e557f58..e6cc6ee4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,53 @@ Changelog Here you can see the full list of changes between each Authlib release. +Version 1.6.12 +-------------- + +**Released on may 4, 2026** + +- Fix redirecting to unvalidated ``redirect_uri`` on ``InvalidScopeError`` + in ``OpenIDImplicitGrant`` and ``OpenIDHybridGrant``. + +Version 1.6.11 +-------------- + +**Released on Apr 16, 2026** + +- Fix CSRF vulnerability in the Starlette OAuth client when a ``cache`` is + configured. + +Version 1.6.10 +-------------- + +**Released on Apr 13, 2026** + +- 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/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() 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): 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 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", 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):