From b65752de69e5084d1c4f5d6f804527312710c830 Mon Sep 17 00:00:00 2001 From: Kevin J Gao <32936811+gaokevin1@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:12:30 -0700 Subject: [PATCH 1/3] added audience override if contains project ID and tests --- descope/auth.py | 27 +++++++++- tests/test_auth.py | 129 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/descope/auth.py b/descope/auth.py index 36c9d4401..3db1414eb 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -656,12 +656,37 @@ def _validate_token( "Algorithm signature in JWT header does not match the algorithm signature in the public key", ) + # Check if we need to auto-detect audience from token + validation_audience = audience + if audience is None: + try: + unverified_claims = jwt.decode( + jwt=token, + key=copy_key[0].key, + algorithms=[alg_header], + options={"verify_aud": False}, # Skip audience verification for now + leeway=self.jwt_validation_leeway, + ) + token_audience = unverified_claims.get("aud") + + # If token has audience claim and it matches our project ID, use it + if token_audience and self.project_id: + if isinstance(token_audience, list): + if self.project_id in token_audience: + validation_audience = self.project_id + else: + if token_audience == self.project_id: + validation_audience = self.project_id + except Exception: + # If we can't decode the token to check audience, proceed with original audience (None) + pass + try: claims = jwt.decode( jwt=token, key=copy_key[0].key, algorithms=[alg_header], - audience=audience, + audience=validation_audience, leeway=self.jwt_validation_leeway, ) except ImmatureSignatureError: diff --git a/tests/test_auth.py b/tests/test_auth.py index 7dba9635f..6b6d9ccf5 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,6 +6,8 @@ from unittest import mock from unittest.mock import patch +import jwt + from descope import ( API_RATE_LIMIT_RETRY_AFTER_HEADER, ERROR_TYPE_API_RATE_LIMIT, @@ -778,6 +780,133 @@ def test_raise_from_response(self): """{"errorCode":"E062108","errorDescription":"User not found","errorMessage":"Cannot find user"}""", ) + def test_validate_session_audience_auto_detection(self): + """Test that validate_session automatically detects audience when token audience matches project ID""" + auth = Auth(self.dummy_project_id, self.public_key_dict) + + with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: + mock_get_header.return_value = {"alg": "ES384", "kid": self.public_key_dict["kid"]} + mock_decode.side_effect = [ + {"aud": self.dummy_project_id, "sub": "user123", "exp": 9999999999}, + {"aud": self.dummy_project_id, "sub": "user123", "exp": 9999999999} + ] + + with patch.object(auth, 'public_keys', {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}): + with patch.object(auth, '_fetch_public_keys'): + result = auth.validate_session("dummy_token") + + self.assertEqual(mock_decode.call_count, 2) + first_call = mock_decode.call_args_list[0] + self.assertIn("options", first_call.kwargs) + self.assertIn("verify_aud", first_call.kwargs["options"]) + self.assertFalse(first_call.kwargs["options"]["verify_aud"]) + second_call = mock_decode.call_args_list[1] + self.assertEqual(second_call.kwargs["audience"], self.dummy_project_id) + + def test_validate_session_audience_auto_detection_list(self): + """Test that validate_session automatically detects audience when token audience is a list containing project ID""" + auth = Auth(self.dummy_project_id, self.public_key_dict) + + with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: + mock_get_header.return_value = {"alg": "ES384", "kid": self.public_key_dict["kid"]} + mock_decode.side_effect = [ + {"aud": [self.dummy_project_id, "other-audience"], "sub": "user123", "exp": 9999999999}, + {"aud": [self.dummy_project_id, "other-audience"], "sub": "user123", "exp": 9999999999} + ] + + with patch.object(auth, 'public_keys', {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}): + with patch.object(auth, '_fetch_public_keys'): + result = auth.validate_session("dummy_token") + + self.assertEqual(mock_decode.call_count, 2) + second_call = mock_decode.call_args_list[1] + self.assertEqual(second_call.kwargs["audience"], self.dummy_project_id) + + def test_validate_session_audience_auto_detection_no_match(self): + """Test that validate_session does not auto-detect audience when token audience doesn't match project ID""" + auth = Auth(self.dummy_project_id, self.public_key_dict) + + with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: + mock_get_header.return_value = {"alg": "ES384", "kid": self.public_key_dict["kid"]} + mock_decode.side_effect = [ + {"aud": "different-project-id", "sub": "user123", "exp": 9999999999}, + {"aud": "different-project-id", "sub": "user123", "exp": 9999999999} + ] + + with patch.object(auth, 'public_keys', {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}): + with patch.object(auth, '_fetch_public_keys'): + result = auth.validate_session("dummy_token") + + self.assertEqual(mock_decode.call_count, 2) + second_call = mock_decode.call_args_list[1] + self.assertIsNone(second_call.kwargs["audience"]) + + def test_validate_session_explicit_audience(self): + """Test that validate_session uses explicit audience parameter instead of auto-detection""" + auth = Auth(self.dummy_project_id, self.public_key_dict) + explicit_audience = "explicit-audience" + + with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: + mock_get_header.return_value = {"alg": "ES384", "kid": self.public_key_dict["kid"]} + mock_decode.return_value = {"aud": explicit_audience, "sub": "user123", "exp": 9999999999} + + with patch.object(auth, 'public_keys', {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}): + with patch.object(auth, '_fetch_public_keys'): + result = auth.validate_session("dummy_token", audience=explicit_audience) + + self.assertEqual(mock_decode.call_count, 1) + call_args = mock_decode.call_args + self.assertEqual(call_args.kwargs["audience"], explicit_audience) + + def test_validate_and_refresh_session_audience_auto_detection(self): + """Test that validate_and_refresh_session automatically detects audience when token audience matches project ID""" + auth = Auth(self.dummy_project_id, self.public_key_dict) + + with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: + mock_get_header.return_value = {"alg": "ES384", "kid": self.public_key_dict["kid"]} + mock_decode.side_effect = [ + {"aud": self.dummy_project_id, "sub": "user123", "exp": 9999999999}, + {"aud": self.dummy_project_id, "sub": "user123", "exp": 9999999999} + ] + + with patch.object(auth, 'public_keys', {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}): + with patch.object(auth, '_fetch_public_keys'): + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + mock_post.return_value.json.return_value = {"sessionJwt": "new_token"} + mock_post.return_value.cookies = {} + + result = auth.validate_and_refresh_session("dummy_session_token", "dummy_refresh_token") + + self.assertEqual(mock_decode.call_count, 2) + first_call = mock_decode.call_args_list[0] + self.assertIn("options", first_call.kwargs) + self.assertIn("verify_aud", first_call.kwargs["options"]) + self.assertFalse(first_call.kwargs["options"]["verify_aud"]) + second_call = mock_decode.call_args_list[1] + self.assertEqual(second_call.kwargs["audience"], self.dummy_project_id) + + def test_validate_session_audience_mismatch_fails(self): + """Test that validate_session fails when token audience doesn't match project ID and no explicit audience is provided""" + auth = Auth(self.dummy_project_id, self.public_key_dict) + + with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: + mock_get_header.return_value = {"alg": "ES384", "kid": self.public_key_dict["kid"]} + # First call succeeds (for audience detection), second call fails (for validation with None audience) + mock_decode.side_effect = [ + {"aud": "different-project-id", "sub": "user123", "exp": 9999999999}, # First call for audience detection + jwt.InvalidAudienceError("Invalid audience") # Second call fails because audience doesn't match + ] + + with patch.object(auth, 'public_keys', {self.public_key_dict["kid"]: (mock.Mock(), "ES384")}): + with patch.object(auth, '_fetch_public_keys'): + with self.assertRaises(jwt.InvalidAudienceError) as cm: + auth.validate_session("dummy_token") + + # Verify the error is about invalid audience + self.assertIn("Invalid audience", str(cm.exception)) + self.assertEqual(mock_decode.call_count, 2) + if __name__ == "__main__": unittest.main() From f50bb51bb64306ebb0f12c913b4396b6a90a28f7 Mon Sep 17 00:00:00 2001 From: Kevin J Gao <32936811+gaokevin1@users.noreply.github.com> Date: Mon, 3 Nov 2025 22:13:54 -0800 Subject: [PATCH 2/3] fixed tests --- tests/test_auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_auth.py b/tests/test_auth.py index ccbd64ff3..88b6caf8d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1076,6 +1076,7 @@ def test_validate_session_audience_mismatch_fails(self): # Verify the error is about invalid audience self.assertIn("Invalid audience", str(cm.exception)) self.assertEqual(mock_decode.call_count, 2) + def test_http_client_authorization_header_variants(self): # Base client without management key client = self.make_http_client() From 3f84b57a1b8d2b01c0ed9aad57795513ee23e634 Mon Sep 17 00:00:00 2001 From: Kevin J Gao <32936811+gaokevin1@users.noreply.github.com> Date: Mon, 3 Nov 2025 22:16:26 -0800 Subject: [PATCH 3/3] added httpClient --- tests/test_auth.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 88b6caf8d..5c9fa7ed6 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -952,7 +952,7 @@ def test_raise_from_response(self): def test_validate_session_audience_auto_detection(self): """Test that validate_session automatically detects audience when token audience matches project ID""" - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth(self.dummy_project_id, self.public_key_dict, http_client=self.make_http_client()) with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: mock_get_header.return_value = {"alg": "ES384", "kid": self.public_key_dict["kid"]} @@ -975,7 +975,7 @@ def test_validate_session_audience_auto_detection(self): def test_validate_session_audience_auto_detection_list(self): """Test that validate_session automatically detects audience when token audience is a list containing project ID""" - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth(self.dummy_project_id, self.public_key_dict, http_client=self.make_http_client()) with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: mock_get_header.return_value = {"alg": "ES384", "kid": self.public_key_dict["kid"]} @@ -994,7 +994,7 @@ def test_validate_session_audience_auto_detection_list(self): def test_validate_session_audience_auto_detection_no_match(self): """Test that validate_session does not auto-detect audience when token audience doesn't match project ID""" - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth(self.dummy_project_id, self.public_key_dict, http_client=self.make_http_client()) with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: mock_get_header.return_value = {"alg": "ES384", "kid": self.public_key_dict["kid"]} @@ -1013,7 +1013,7 @@ def test_validate_session_audience_auto_detection_no_match(self): def test_validate_session_explicit_audience(self): """Test that validate_session uses explicit audience parameter instead of auto-detection""" - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth(self.dummy_project_id, self.public_key_dict, http_client=self.make_http_client()) explicit_audience = "explicit-audience" with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: @@ -1030,7 +1030,7 @@ def test_validate_session_explicit_audience(self): def test_validate_and_refresh_session_audience_auto_detection(self): """Test that validate_and_refresh_session automatically detects audience when token audience matches project ID""" - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth(self.dummy_project_id, self.public_key_dict, http_client=self.make_http_client()) with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: mock_get_header.return_value = {"alg": "ES384", "kid": self.public_key_dict["kid"]} @@ -1058,7 +1058,7 @@ def test_validate_and_refresh_session_audience_auto_detection(self): def test_validate_session_audience_mismatch_fails(self): """Test that validate_session fails when token audience doesn't match project ID and no explicit audience is provided""" - auth = Auth(self.dummy_project_id, self.public_key_dict) + auth = Auth(self.dummy_project_id, self.public_key_dict, http_client=self.make_http_client()) with patch("jwt.get_unverified_header") as mock_get_header, patch("jwt.decode") as mock_decode: mock_get_header.return_value = {"alg": "ES384", "kid": self.public_key_dict["kid"]}