From bf589cf8f93c10554b65ce23df43650f729ec906 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Mon, 18 Aug 2025 14:15:44 +0000 Subject: [PATCH 1/4] feat: add OAuth2 scopes parameter support to CredentialConfiguration --- openfga_sdk/credentials.py | 17 ++++++ openfga_sdk/oauth2.py | 7 +++ openfga_sdk/sync/oauth2.py | 7 +++ test/credentials_test.py | 36 ++++++++++++ test/sync/oauth2_test.py | 113 +++++++++++++++++++++++++++++++++++++ 5 files changed, 180 insertions(+) diff --git a/openfga_sdk/credentials.py b/openfga_sdk/credentials.py index c2bcdf2b..9e50a6e9 100644 --- a/openfga_sdk/credentials.py +++ b/openfga_sdk/credentials.py @@ -30,6 +30,7 @@ class CredentialConfiguration: :param api_token: Bearer token to be sent for authentication :param api_audience: API audience used for OAuth2 :param api_issuer: API issuer used for OAuth2 + :param scopes: OAuth2 scopes to request, can be a list of strings or a space-separated string """ def __init__( @@ -39,12 +40,14 @@ def __init__( api_audience: str | None = None, api_issuer: str | None = None, api_token: str | None = None, + scopes: str | list[str] | None = None, ): self._client_id = client_id self._client_secret = client_secret self._api_audience = api_audience self._api_issuer = api_issuer self._api_token = api_token + self._scopes = scopes @property def client_id(self): @@ -116,6 +119,20 @@ def api_token(self, value): """ self._api_token = value + @property + def scopes(self): + """ + Return the scopes configured + """ + return self._scopes + + @scopes.setter + def scopes(self, value): + """ + Update the scopes + """ + self._scopes = value + class Credentials: """ diff --git a/openfga_sdk/oauth2.py b/openfga_sdk/oauth2.py index 54052867..060acf53 100644 --- a/openfga_sdk/oauth2.py +++ b/openfga_sdk/oauth2.py @@ -79,6 +79,13 @@ async def _obtain_token(self, client): "grant_type": "client_credentials", } + # Add scope parameter if scopes are configured + if configuration.scopes is not None: + if isinstance(configuration.scopes, list): + post_params["scope"] = " ".join(configuration.scopes) + else: + post_params["scope"] = configuration.scopes + headers = urllib3.response.HTTPHeaderDict( { "Accept": "application/json", diff --git a/openfga_sdk/sync/oauth2.py b/openfga_sdk/sync/oauth2.py index 9e3b8b7d..ad4336cb 100644 --- a/openfga_sdk/sync/oauth2.py +++ b/openfga_sdk/sync/oauth2.py @@ -79,6 +79,13 @@ def _obtain_token(self, client): "grant_type": "client_credentials", } + # Add scope parameter if scopes are configured + if configuration.scopes is not None: + if isinstance(configuration.scopes, list): + post_params["scope"] = " ".join(configuration.scopes) + else: + post_params["scope"] = configuration.scopes + headers = urllib3.response.HTTPHeaderDict( { "Accept": "application/json", diff --git a/test/credentials_test.py b/test/credentials_test.py index 238608f3..d3bc446d 100644 --- a/test/credentials_test.py +++ b/test/credentials_test.py @@ -107,6 +107,42 @@ def test_configuration_client_credentials(self): credential.validate_credentials_config() self.assertEqual(credential.method, "client_credentials") + def test_configuration_client_credentials_with_scopes_list(self): + """ + Test credential with method client_credentials and scopes as list is valid + """ + credential = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + api_audience="myaudience", + scopes=["read", "write", "admin"], + ), + ) + credential.validate_credentials_config() + self.assertEqual(credential.method, "client_credentials") + self.assertEqual(credential.configuration.scopes, ["read", "write", "admin"]) + + def test_configuration_client_credentials_with_scopes_string(self): + """ + Test credential with method client_credentials and scopes as string is valid + """ + credential = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + api_audience="myaudience", + scopes="read write admin", + ), + ) + credential.validate_credentials_config() + self.assertEqual(credential.method, "client_credentials") + self.assertEqual(credential.configuration.scopes, "read write admin") + def test_configuration_client_credentials_missing_config(self): """ Test credential with method client_credentials and configuration is missing diff --git a/test/sync/oauth2_test.py b/test/sync/oauth2_test.py index 0a872e13..fd4f39c1 100644 --- a/test/sync/oauth2_test.py +++ b/test/sync/oauth2_test.py @@ -104,6 +104,119 @@ def test_get_authentication_obtain_client_credentials(self, mock_request): ) rest_client.close() + @patch.object(rest.RESTClientObject, "request") + def test_get_authentication_obtain_client_credentials_with_scopes_list(self, mock_request): + """ + Test getting authentication header when method is client credentials with scopes as list + """ + response_body = """ +{ + "expires_in": 120, + "access_token": "AABBCCDD" +} + """ + mock_request.return_value = mock_response(response_body, 200) + + credentials = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + api_audience="myaudience", + scopes=["read", "write", "admin"], + ), + ) + rest_client = rest.RESTClientObject(Configuration()) + current_time = datetime.now() + client = OAuth2Client(credentials) + auth_header = client.get_authentication_header(rest_client) + self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"}) + self.assertEqual(client._access_token, "AABBCCDD") + self.assertGreaterEqual( + client._access_expiry_time, current_time + timedelta(seconds=120) + ) + expected_header = urllib3.response.HTTPHeaderDict( + { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "openfga-sdk (python) 0.9.5", + } + ) + mock_request.assert_called_once_with( + method="POST", + url="https://issuer.fga.example/oauth/token", + headers=expected_header, + query_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + post_params={ + "client_id": "myclientid", + "client_secret": "mysecret", + "audience": "myaudience", + "grant_type": "client_credentials", + "scope": "read write admin", + }, + ) + rest_client.close() + + @patch.object(rest.RESTClientObject, "request") + def test_get_authentication_obtain_client_credentials_with_scopes_string(self, mock_request): + """ + Test getting authentication header when method is client credentials with scopes as string + """ + response_body = """ +{ + "expires_in": 120, + "access_token": "AABBCCDD" +} + """ + mock_request.return_value = mock_response(response_body, 200) + + credentials = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + api_audience="myaudience", + scopes="read write admin", + ), + ) + rest_client = rest.RESTClientObject(Configuration()) + current_time = datetime.now() + client = OAuth2Client(credentials) + auth_header = client.get_authentication_header(rest_client) + self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"}) + self.assertEqual(client._access_token, "AABBCCDD") + self.assertGreaterEqual( + client._access_expiry_time, current_time + timedelta(seconds=120) + ) + expected_header = urllib3.response.HTTPHeaderDict( + { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "openfga-sdk (python) 0.9.5", + } + ) + mock_request.assert_called_once_with( + method="POST", + url="https://issuer.fga.example/oauth/token", + headers=expected_header, + query_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + post_params={ + "client_id": "myclientid", + "client_secret": "mysecret", + "audience": "myaudience", + "grant_type": "client_credentials", + "scope": "read write admin", + }, + ) + @patch.object(rest.RESTClientObject, "request") def test_get_authentication_obtain_client_credentials_failed(self, mock_request): """ From 18d1769b0efc75107c9e4981ce0e61c767f71920 Mon Sep 17 00:00:00 2001 From: SoulPancake Date: Wed, 20 Aug 2025 13:11:06 +0530 Subject: [PATCH 2/4] feat: add async tests --- test/oauth2_test.py | 132 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/test/oauth2_test.py b/test/oauth2_test.py index 0b5adefb..0cac9faa 100644 --- a/test/oauth2_test.py +++ b/test/oauth2_test.py @@ -12,7 +12,13 @@ from datetime import datetime, timedelta from unittest import IsolatedAsyncioTestCase -from unittest.mock import patch + +import unittest +import pytest +from unittest.mock import patch, MagicMock +from openfga_sdk.credentials import CredentialConfiguration +from openfga_sdk.oauth2 import OAuth2Client + import urllib3 @@ -494,3 +500,127 @@ async def test_get_authentication_add_scheme_and_path(self, mock_request): }, ) await rest_client.close() + +class TestOAuth2ClientScopeHandling(unittest.TestCase): + """Test OAuth2Client scope serialization in synchronous context""" + + def setUp(self): + self.config = CredentialConfiguration( + client_id="test_client_id", + client_secret="test_client_secret", + api_issuer="https://example.com", + api_audience="test_audience" + ) + self.oauth_client = OAuth2Client(self.config) + + @patch('requests.post') + def test_list_scopes_serialization(self, mock_post): + """Test that list scopes are serialized as space-delimited string""" + # Setup mock response + mock_response = MagicMock() + mock_response.json.return_value = {"access_token": "test_token", "expires_in": 3600} + mock_response.status_code = 200 + mock_post.return_value = mock_response + + # Set list scopes + self.config.scopes = ["read", "write", "admin"] + + # Make token request + self.oauth_client.request_token() + + # Verify scope parameter was correctly formatted + args, kwargs = mock_post.call_args + self.assertIn('data', kwargs) + self.assertIn('scope', kwargs['data']) + self.assertEqual(kwargs['data']['scope'], "read write admin") + + @patch('requests.post') + def test_string_scope_serialization(self, mock_post): + """Test that string scope is used unchanged""" + # Setup mock response + mock_response = MagicMock() + mock_response.json.return_value = {"access_token": "test_token", "expires_in": 3600} + mock_response.status_code = 200 + mock_post.return_value = mock_response + + # Set string scope + self.config.scopes = "read write admin" + + # Make token request + self.oauth_client.request_token() + + # Verify scope parameter was passed unchanged + args, kwargs = mock_post.call_args + self.assertIn('data', kwargs) + self.assertIn('scope', kwargs['data']) + self.assertEqual(kwargs['data']['scope'], "read write admin") + + +class TestOAuth2ClientScopeHandlingAsync: + """Test OAuth2Client scope serialization in asynchronous context""" + + @pytest.fixture + def oauth_client(self): + config = CredentialConfiguration( + client_id="test_client_id", + client_secret="test_client_secret", + api_issuer="https://example.com", + api_audience="test_audience" + ) + return OAuth2Client(config) + + @pytest.mark.asyncio + async def test_list_scopes_serialization_async(self, oauth_client, monkeypatch): + """Test that list scopes are serialized as space-delimited string in async context""" + # Mock the async HTTP response + mock_response = MagicMock() + mock_response.json = MagicMock(return_value={"access_token": "test_token", "expires_in": 3600}) + mock_response.status_code = 200 + + # Set up mock for the async HTTP client + mock_client_session = MagicMock() + mock_client_session.post = MagicMock(return_value=mock_response) + mock_client_session.__aenter__ = MagicMock(return_value=mock_client_session) + mock_client_session.__aexit__ = MagicMock(return_value=None) + + monkeypatch.setattr('aiohttp.ClientSession', MagicMock(return_value=mock_client_session)) + + # Set list scopes + oauth_client.configuration.scopes = ["read", "write", "admin"] + + # Make async token request + await oauth_client.request_token_async() + + # Verify scope parameter was correctly formatted + args, kwargs = mock_client_session.post.call_args + self.assertIn('data', kwargs) + self.assertIn('scope', kwargs['data']) + self.assertEqual(kwargs['data']['scope'], "read write admin") + + @pytest.mark.asyncio + async def test_string_scope_serialization_async(self, oauth_client, monkeypatch): + """Test that string scope is used unchanged in async context""" + # Mock the async HTTP response + mock_response = MagicMock() + mock_response.json = MagicMock(return_value={"access_token": "test_token", "expires_in": 3600}) + mock_response.status_code = 200 + + # Set up mock for the async HTTP client + mock_client_session = MagicMock() + mock_client_session.post = MagicMock(return_value=mock_response) + mock_client_session.__aenter__ = MagicMock(return_value=mock_client_session) + mock_client_session.__aexit__ = MagicMock(return_value=None) + + monkeypatch.setattr('aiohttp.ClientSession', MagicMock(return_value=mock_client_session)) + + # Set string scope + oauth_client.configuration.scopes = "read write admin" + + # Make async token request + await oauth_client.request_token_async() + + # Verify scope parameter was passed unchanged + args, kwargs = mock_client_session.post.call_args + self.assertIn('data', kwargs) + self.assertIn('scope', kwargs['data']) + self.assertEqual(kwargs['data']['scope'], "read write admin") \ No newline at end of file From 21ea81972f25efab073005b49289b80c04e6ef1c Mon Sep 17 00:00:00 2001 From: SoulPancake Date: Wed, 20 Aug 2025 13:24:19 +0530 Subject: [PATCH 3/4] fix: tests --- test/oauth2_test.py | 242 +++++++++++++++++++++----------------------- 1 file changed, 113 insertions(+), 129 deletions(-) diff --git a/test/oauth2_test.py b/test/oauth2_test.py index 0cac9faa..ba43806c 100644 --- a/test/oauth2_test.py +++ b/test/oauth2_test.py @@ -12,13 +12,7 @@ from datetime import datetime, timedelta from unittest import IsolatedAsyncioTestCase - -import unittest -import pytest -from unittest.mock import patch, MagicMock -from openfga_sdk.credentials import CredentialConfiguration -from openfga_sdk.oauth2 import OAuth2Client - +from unittest.mock import patch import urllib3 @@ -501,126 +495,116 @@ async def test_get_authentication_add_scheme_and_path(self, mock_request): ) await rest_client.close() -class TestOAuth2ClientScopeHandling(unittest.TestCase): - """Test OAuth2Client scope serialization in synchronous context""" + @patch.object(rest.RESTClientObject, "request") + async def test_get_authentication_obtain_client_credentials_with_scopes_list(self, mock_request): + """ + Test getting authentication header when method is client credentials with scopes as list + """ + response_body = """ +{ + "expires_in": 120, + "access_token": "AABBCCDD" +} + """ + mock_request.return_value = mock_response(response_body, 200) - def setUp(self): - self.config = CredentialConfiguration( - client_id="test_client_id", - client_secret="test_client_secret", - api_issuer="https://example.com", - api_audience="test_audience" - ) - self.oauth_client = OAuth2Client(self.config) - - @patch('requests.post') - def test_list_scopes_serialization(self, mock_post): - """Test that list scopes are serialized as space-delimited string""" - # Setup mock response - mock_response = MagicMock() - mock_response.json.return_value = {"access_token": "test_token", "expires_in": 3600} - mock_response.status_code = 200 - mock_post.return_value = mock_response - - # Set list scopes - self.config.scopes = ["read", "write", "admin"] - - # Make token request - self.oauth_client.request_token() - - # Verify scope parameter was correctly formatted - args, kwargs = mock_post.call_args - self.assertIn('data', kwargs) - self.assertIn('scope', kwargs['data']) - self.assertEqual(kwargs['data']['scope'], "read write admin") - - @patch('requests.post') - def test_string_scope_serialization(self, mock_post): - """Test that string scope is used unchanged""" - # Setup mock response - mock_response = MagicMock() - mock_response.json.return_value = {"access_token": "test_token", "expires_in": 3600} - mock_response.status_code = 200 - mock_post.return_value = mock_response - - # Set string scope - self.config.scopes = "read write admin" - - # Make token request - self.oauth_client.request_token() - - # Verify scope parameter was passed unchanged - args, kwargs = mock_post.call_args - self.assertIn('data', kwargs) - self.assertIn('scope', kwargs['data']) - self.assertEqual(kwargs['data']['scope'], "read write admin") - - -class TestOAuth2ClientScopeHandlingAsync: - """Test OAuth2Client scope serialization in asynchronous context""" - - @pytest.fixture - def oauth_client(self): - config = CredentialConfiguration( - client_id="test_client_id", - client_secret="test_client_secret", - api_issuer="https://example.com", - api_audience="test_audience" - ) - return OAuth2Client(config) - - @pytest.mark.asyncio - async def test_list_scopes_serialization_async(self, oauth_client, monkeypatch): - """Test that list scopes are serialized as space-delimited string in async context""" - # Mock the async HTTP response - mock_response = MagicMock() - mock_response.json = MagicMock(return_value={"access_token": "test_token", "expires_in": 3600}) - mock_response.status_code = 200 - - # Set up mock for the async HTTP client - mock_client_session = MagicMock() - mock_client_session.post = MagicMock(return_value=mock_response) - mock_client_session.__aenter__ = MagicMock(return_value=mock_client_session) - mock_client_session.__aexit__ = MagicMock(return_value=None) - - monkeypatch.setattr('aiohttp.ClientSession', MagicMock(return_value=mock_client_session)) - - # Set list scopes - oauth_client.configuration.scopes = ["read", "write", "admin"] - - # Make async token request - await oauth_client.request_token_async() - - # Verify scope parameter was correctly formatted - args, kwargs = mock_client_session.post.call_args - self.assertIn('data', kwargs) - self.assertIn('scope', kwargs['data']) - self.assertEqual(kwargs['data']['scope'], "read write admin") - - @pytest.mark.asyncio - async def test_string_scope_serialization_async(self, oauth_client, monkeypatch): - """Test that string scope is used unchanged in async context""" - # Mock the async HTTP response - mock_response = MagicMock() - mock_response.json = MagicMock(return_value={"access_token": "test_token", "expires_in": 3600}) - mock_response.status_code = 200 - - # Set up mock for the async HTTP client - mock_client_session = MagicMock() - mock_client_session.post = MagicMock(return_value=mock_response) - mock_client_session.__aenter__ = MagicMock(return_value=mock_client_session) - mock_client_session.__aexit__ = MagicMock(return_value=None) - - monkeypatch.setattr('aiohttp.ClientSession', MagicMock(return_value=mock_client_session)) - - # Set string scope - oauth_client.configuration.scopes = "read write admin" - - # Make async token request - await oauth_client.request_token_async() - - # Verify scope parameter was passed unchanged - args, kwargs = mock_client_session.post.call_args - self.assertIn('data', kwargs) - self.assertIn('scope', kwargs['data']) - self.assertEqual(kwargs['data']['scope'], "read write admin") \ No newline at end of file + credentials = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + api_audience="myaudience", + scopes=["read", "write", "admin"], + ), + ) + rest_client = rest.RESTClientObject(Configuration()) + current_time = datetime.now() + client = OAuth2Client(credentials) + auth_header = await client.get_authentication_header(rest_client) + self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"}) + self.assertEqual(client._access_token, "AABBCCDD") + self.assertGreaterEqual( + client._access_expiry_time, current_time + timedelta(seconds=120) + ) + expected_header = urllib3.response.HTTPHeaderDict( + { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "openfga-sdk (python) 0.9.5", + } + ) + mock_request.assert_called_once_with( + method="POST", + url="https://issuer.fga.example/oauth/token", + headers=expected_header, + query_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + post_params={ + "client_id": "myclientid", + "client_secret": "mysecret", + "audience": "myaudience", + "grant_type": "client_credentials", + "scope": "read write admin", + }, + ) + await rest_client.close() + + @patch.object(rest.RESTClientObject, "request") + async def test_get_authentication_obtain_client_credentials_with_scopes_string(self, mock_request): + """ + Test getting authentication header when method is client credentials with scopes as string + """ + response_body = """ +{ + "expires_in": 120, + "access_token": "AABBCCDD" +} + """ + mock_request.return_value = mock_response(response_body, 200) + + credentials = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + api_audience="myaudience", + scopes="read write admin", + ), + ) + rest_client = rest.RESTClientObject(Configuration()) + current_time = datetime.now() + client = OAuth2Client(credentials) + auth_header = await client.get_authentication_header(rest_client) + self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"}) + self.assertEqual(client._access_token, "AABBCCDD") + self.assertGreaterEqual( + client._access_expiry_time, current_time + timedelta(seconds=120) + ) + expected_header = urllib3.response.HTTPHeaderDict( + { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "openfga-sdk (python) 0.9.5", + } + ) + mock_request.assert_called_once_with( + method="POST", + url="https://issuer.fga.example/oauth/token", + headers=expected_header, + query_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + post_params={ + "client_id": "myclientid", + "client_secret": "mysecret", + "audience": "myaudience", + "grant_type": "client_credentials", + "scope": "read write admin", + }, + ) + await rest_client.close() \ No newline at end of file From a0eeaa4cbd03c9112aa7c653d4dd2ce7c48c2242 Mon Sep 17 00:00:00 2001 From: SoulPancake Date: Tue, 2 Sep 2025 22:12:48 +0530 Subject: [PATCH 4/4] feat: run ruff lint fix --- test/oauth2_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/oauth2_test.py b/test/oauth2_test.py index ba43806c..28cabec1 100644 --- a/test/oauth2_test.py +++ b/test/oauth2_test.py @@ -607,4 +607,4 @@ async def test_get_authentication_obtain_client_credentials_with_scopes_string(s "scope": "read write admin", }, ) - await rest_client.close() \ No newline at end of file + await rest_client.close()