From bc2c2b13f1293ac6a58f0253ed76569bab7675b6 Mon Sep 17 00:00:00 2001 From: Anurag Bandyopadhyay Date: Thu, 16 Apr 2026 18:39:46 +0530 Subject: [PATCH 1/3] feat: oauth2 scopes for authn --- README.md | 3 +- openfga_sdk/credentials.py | 3 +- openfga_sdk/oauth2.py | 4 +- openfga_sdk/sync/oauth2.py | 4 +- test/credentials_test.py | 10 ++-- test/oauth2_test.py | 100 +++++++++++++++++++++++++++++++++++++ test/sync/oauth2_test.py | 100 +++++++++++++++++++++++++++++++++++++ 7 files changed, 215 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0409c37..cc1eb0e 100644 --- a/README.md +++ b/README.md @@ -189,9 +189,10 @@ async def main(): method='client_credentials', configuration=CredentialConfiguration( api_issuer=FGA_API_TOKEN_ISSUER, - api_audience=FGA_API_AUDIENCE, + api_audience=FGA_API_AUDIENCE, # optional, required for Auth0; omit for standard OAuth2 client_id=FGA_CLIENT_ID, client_secret=FGA_CLIENT_SECRET, + # scopes="read write", # optional, space-separated OAuth2 scopes ) ) ) diff --git a/openfga_sdk/credentials.py b/openfga_sdk/credentials.py index 5cae254..f31d23c 100644 --- a/openfga_sdk/credentials.py +++ b/openfga_sdk/credentials.py @@ -215,11 +215,10 @@ def validate_credentials_config(self): self.configuration is None or none_or_empty(self.configuration.client_id) or none_or_empty(self.configuration.client_secret) - or none_or_empty(self.configuration.api_audience) or none_or_empty(self.configuration.api_issuer) ): raise ApiValueError( - "configuration `{}` requires client_id, client_secret, api_audience and api_issuer defined for client_credentials method." + "configuration `{}` requires client_id, client_secret and api_issuer defined for client_credentials method." ) # validate token issuer diff --git a/openfga_sdk/oauth2.py b/openfga_sdk/oauth2.py index 61b6aab..759d9ed 100644 --- a/openfga_sdk/oauth2.py +++ b/openfga_sdk/oauth2.py @@ -64,10 +64,12 @@ async def _obtain_token(self, client): post_params = { "client_id": configuration.client_id, "client_secret": configuration.client_secret, - "audience": configuration.api_audience, "grant_type": "client_credentials", } + if configuration.api_audience is not None: + post_params["audience"] = configuration.api_audience + # Add scope parameter if scopes are configured if configuration.scopes is not None: if isinstance(configuration.scopes, list): diff --git a/openfga_sdk/sync/oauth2.py b/openfga_sdk/sync/oauth2.py index b870739..e6ae9f8 100644 --- a/openfga_sdk/sync/oauth2.py +++ b/openfga_sdk/sync/oauth2.py @@ -64,10 +64,12 @@ def _obtain_token(self, client): post_params = { "client_id": configuration.client_id, "client_secret": configuration.client_secret, - "audience": configuration.api_audience, "grant_type": "client_credentials", } + if configuration.api_audience is not None: + post_params["audience"] = configuration.api_audience + # Add scope parameter if scopes are configured if configuration.scopes is not None: if isinstance(configuration.scopes, list): diff --git a/test/credentials_test.py b/test/credentials_test.py index 9cf5dcf..39e27f9 100644 --- a/test/credentials_test.py +++ b/test/credentials_test.py @@ -184,9 +184,10 @@ def test_configuration_client_credentials_missing_api_issuer(self): with self.assertRaises(openfga_sdk.ApiValueError): credential.validate_credentials_config() - def test_configuration_client_credentials_missing_api_audience(self): + def test_configuration_client_credentials_without_api_audience(self): """ - Test credential with method client_credentials and configuration is missing api audience + Test credential with method client_credentials and no api_audience is valid + (audience is optional for standard OAuth2 servers) """ credential = Credentials( method="client_credentials", @@ -196,8 +197,9 @@ def test_configuration_client_credentials_missing_api_audience(self): api_issuer="issuer.fga.example", ), ) - with self.assertRaises(openfga_sdk.ApiValueError): - credential.validate_credentials_config() + credential.validate_credentials_config() + self.assertEqual(credential.method, "client_credentials") + self.assertIsNone(credential.configuration.api_audience) class TestCredentialsIssuer(IsolatedAsyncioTestCase): diff --git a/test/oauth2_test.py b/test/oauth2_test.py index 56b1efd..48b5030 100644 --- a/test/oauth2_test.py +++ b/test/oauth2_test.py @@ -601,3 +601,103 @@ async def test_get_authentication_obtain_client_credentials_with_scopes_string( }, ) await rest_client.close() + + @patch.object(rest.RESTClientObject, "request") + async def test_get_authentication_without_audience(self, mock_request): + """ + Test that audience is omitted from the token request when not provided + (standard OAuth2 flow without Auth0 audience extension) + """ + 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", + ), + ) + rest_client = rest.RESTClientObject(Configuration()) + client = OAuth2Client(credentials) + auth_header = await client.get_authentication_header(rest_client) + self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"}) + expected_header = urllib3.response.HTTPHeaderDict( + { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENT, + } + ) + 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", + "grant_type": "client_credentials", + }, + ) + await rest_client.close() + + @patch.object(rest.RESTClientObject, "request") + async def test_get_authentication_with_scopes_no_audience(self, mock_request): + """ + Test that scope is sent and audience is omitted when only scopes are provided + (standard OAuth2 flow) + """ + 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", + scopes="read write", + ), + ) + rest_client = rest.RESTClientObject(Configuration()) + client = OAuth2Client(credentials) + auth_header = await client.get_authentication_header(rest_client) + self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"}) + expected_header = urllib3.response.HTTPHeaderDict( + { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENT, + } + ) + 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", + "grant_type": "client_credentials", + "scope": "read write", + }, + ) + await rest_client.close() diff --git a/test/sync/oauth2_test.py b/test/sync/oauth2_test.py index b15fffc..27ae1f2 100644 --- a/test/sync/oauth2_test.py +++ b/test/sync/oauth2_test.py @@ -378,4 +378,104 @@ def test_get_authentication_retries_5xx_responses(self, mock_request): self.assertEqual(mock_request.call_count, 4) # 3 retries, 1 success self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"}) + @patch.object(rest.RESTClientObject, "request") + def test_get_authentication_without_audience(self, mock_request): + """ + Test that audience is omitted from the token request when not provided + (standard OAuth2 flow without Auth0 audience extension) + """ + 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", + ), + ) + rest_client = rest.RESTClientObject(Configuration()) + client = OAuth2Client(credentials) + auth_header = client.get_authentication_header(rest_client) + self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"}) + expected_header = urllib3.response.HTTPHeaderDict( + { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENT, + } + ) + 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", + "grant_type": "client_credentials", + }, + ) + rest_client.close() + + @patch.object(rest.RESTClientObject, "request") + def test_get_authentication_with_scopes_no_audience(self, mock_request): + """ + Test that scope is sent and audience is omitted when only scopes are provided + (standard OAuth2 flow) + """ + 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", + scopes="read write", + ), + ) + rest_client = rest.RESTClientObject(Configuration()) + client = OAuth2Client(credentials) + auth_header = client.get_authentication_header(rest_client) + self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"}) + expected_header = urllib3.response.HTTPHeaderDict( + { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": USER_AGENT, + } + ) + 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", + "grant_type": "client_credentials", + "scope": "read write", + }, + ) + rest_client.close() + rest_client.close() From ad6a45394f7c4771c3886607c073c3736b1c0dfc Mon Sep 17 00:00:00 2001 From: SoulPancake Date: Thu, 16 Apr 2026 22:16:29 +0530 Subject: [PATCH 2/3] feat: add blank.whitespace handling + docs --- README.md | 31 +++++++++++ openfga_sdk/credentials.py | 21 +++++++ openfga_sdk/oauth2.py | 15 ++++- openfga_sdk/sync/oauth2.py | 15 ++++- test/credentials_test.py | 111 +++++++++++++++++++++++++++++++++++-- 5 files changed, 181 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index cc1eb0e..819a466 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,37 @@ async def main(): return api_response ``` +> **Note:** `api_issuer` accepts either a hostname (e.g., `issuer.fga.example`, which defaults to `https:///oauth/token`) or a full token endpoint URL (e.g., `https://oauth.fga.example/token`). Use the full URL when your OAuth2 provider uses a non-standard token endpoint path. + +#### OAuth2 Client Credentials (Standard OAuth2) + +For OAuth2 providers that use `scope` instead of `audience`: + +```python +from openfga_sdk import ClientConfiguration, OpenFgaClient +from openfga_sdk.credentials import Credentials, CredentialConfiguration + + +async def main(): + configuration = ClientConfiguration( + api_url=FGA_API_URL, # required + store_id=FGA_STORE_ID, # optional + authorization_model_id=FGA_MODEL_ID, # optional + credentials=Credentials( + method='client_credentials', + configuration=CredentialConfiguration( + api_issuer="https://oauth.fga.example/token", # full token endpoint URL + client_id=FGA_CLIENT_ID, + client_secret=FGA_CLIENT_SECRET, + scopes="email profile", # space-separated OAuth2 scopes + ) + ) + ) + async with OpenFgaClient(configuration) as fga_client: + api_response = await fga_client.read_authorization_models() + return api_response +``` + ### Custom Headers #### Default Headers diff --git a/openfga_sdk/credentials.py b/openfga_sdk/credentials.py index f31d23c..f461adf 100644 --- a/openfga_sdk/credentials.py +++ b/openfga_sdk/credentials.py @@ -221,5 +221,26 @@ def validate_credentials_config(self): "configuration `{}` requires client_id, client_secret and api_issuer defined for client_credentials method." ) + # Normalize blank/whitespace values to None + # (common misconfiguration from env vars like FGA_API_AUDIENCE="") + if ( + isinstance(self.configuration.api_audience, str) + and self.configuration.api_audience.strip() == "" + ): + self.configuration.api_audience = None + if ( + isinstance(self.configuration.scopes, str) + and self.configuration.scopes.strip() == "" + ): + self.configuration.scopes = None + if isinstance(self.configuration.scopes, list): + self.configuration.scopes = [ + s + for s in self.configuration.scopes + if isinstance(s, str) and s.strip() + ] + if not self.configuration.scopes: + self.configuration.scopes = None + # validate token issuer self._parse_issuer(self.configuration.api_issuer) diff --git a/openfga_sdk/oauth2.py b/openfga_sdk/oauth2.py index 759d9ed..2161f44 100644 --- a/openfga_sdk/oauth2.py +++ b/openfga_sdk/oauth2.py @@ -67,15 +67,24 @@ async def _obtain_token(self, client): "grant_type": "client_credentials", } - if configuration.api_audience is not None: + if ( + configuration.api_audience is not None + and configuration.api_audience.strip() + ): post_params["audience"] = configuration.api_audience # Add scope parameter if scopes are configured if configuration.scopes is not None: if isinstance(configuration.scopes, list): - post_params["scope"] = " ".join(configuration.scopes) + scope_str = " ".join(s for s in configuration.scopes if s and s.strip()) else: - post_params["scope"] = configuration.scopes + scope_str = ( + configuration.scopes.strip() + if isinstance(configuration.scopes, str) + else "" + ) + if scope_str: + post_params["scope"] = scope_str headers = urllib3.response.HTTPHeaderDict( { diff --git a/openfga_sdk/sync/oauth2.py b/openfga_sdk/sync/oauth2.py index e6ae9f8..0602c33 100644 --- a/openfga_sdk/sync/oauth2.py +++ b/openfga_sdk/sync/oauth2.py @@ -67,15 +67,24 @@ def _obtain_token(self, client): "grant_type": "client_credentials", } - if configuration.api_audience is not None: + if ( + configuration.api_audience is not None + and configuration.api_audience.strip() + ): post_params["audience"] = configuration.api_audience # Add scope parameter if scopes are configured if configuration.scopes is not None: if isinstance(configuration.scopes, list): - post_params["scope"] = " ".join(configuration.scopes) + scope_str = " ".join(s for s in configuration.scopes if s and s.strip()) else: - post_params["scope"] = configuration.scopes + scope_str = ( + configuration.scopes.strip() + if isinstance(configuration.scopes, str) + else "" + ) + if scope_str: + post_params["scope"] = scope_str headers = urllib3.response.HTTPHeaderDict( { diff --git a/test/credentials_test.py b/test/credentials_test.py index 39e27f9..4c2b5d6 100644 --- a/test/credentials_test.py +++ b/test/credentials_test.py @@ -201,11 +201,110 @@ def test_configuration_client_credentials_without_api_audience(self): self.assertEqual(credential.method, "client_credentials") self.assertIsNone(credential.configuration.api_audience) + def test_configuration_client_credentials_blank_api_audience_normalized(self): + """ + Test that blank/whitespace api_audience is normalized to None + (common misconfiguration from env vars like FGA_API_AUDIENCE="") + """ + credential = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + api_audience="", + ), + ) + credential.validate_credentials_config() + self.assertIsNone(credential.configuration.api_audience) + + def test_configuration_client_credentials_whitespace_api_audience_normalized(self): + """ + Test that whitespace-only api_audience is normalized to None + """ + credential = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + api_audience=" ", + ), + ) + credential.validate_credentials_config() + self.assertIsNone(credential.configuration.api_audience) + + def test_configuration_client_credentials_blank_scopes_normalized(self): + """ + Test that blank scopes string is normalized to None + """ + credential = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + scopes="", + ), + ) + credential.validate_credentials_config() + self.assertIsNone(credential.configuration.scopes) + + def test_configuration_client_credentials_whitespace_scopes_normalized(self): + """ + Test that whitespace-only scopes string is normalized to None + """ + credential = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + scopes=" ", + ), + ) + credential.validate_credentials_config() + self.assertIsNone(credential.configuration.scopes) + + def test_configuration_client_credentials_empty_scopes_list_normalized(self): + """ + Test that empty scopes list is normalized to None + """ + credential = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + scopes=[], + ), + ) + credential.validate_credentials_config() + self.assertIsNone(credential.configuration.scopes) + + def test_configuration_client_credentials_blank_scopes_list_normalized(self): + """ + Test that scopes list with only blank strings is normalized to None + """ + credential = Credentials( + method="client_credentials", + configuration=CredentialConfiguration( + client_id="myclientid", + client_secret="mysecret", + api_issuer="issuer.fga.example", + scopes=["", " "], + ), + ) + credential.validate_credentials_config() + self.assertIsNone(credential.configuration.scopes) + class TestCredentialsIssuer(IsolatedAsyncioTestCase): def setUp(self): # Setup a basic configuration that can be modified per test case - self.configuration = CredentialConfiguration(api_issuer="https://example.com") + self.configuration = CredentialConfiguration( + api_issuer="https://abc.fga.example" + ) self.credentials = Credentials( method="client_credentials", configuration=self.configuration ) @@ -218,15 +317,15 @@ def test_valid_issuer_https(self): def test_valid_issuer_with_oauth_endpoint_https(self): # Test a valid HTTPS URL - self.configuration.api_issuer = "https://example.com/oauth/token" + self.configuration.api_issuer = "https://abc.fga.example/oauth/token" result = self.credentials._parse_issuer(self.configuration.api_issuer) - self.assertEqual(result, "https://example.com/oauth/token") + self.assertEqual(result, "https://abc.fga.example/oauth/token") def test_valid_issuer_with_some_endpoint_https(self): # Test a valid HTTPS URL - self.configuration.api_issuer = "https://example.com/oauth/some/endpoint" + self.configuration.api_issuer = "https://abc.fga.example/oauth/some/endpoint" result = self.credentials._parse_issuer(self.configuration.api_issuer) - self.assertEqual(result, "https://example.com/oauth/some/endpoint") + self.assertEqual(result, "https://abc.fga.example/oauth/some/endpoint") def test_valid_issuer_http(self): # Test a valid HTTP URL @@ -244,7 +343,7 @@ def test_invalid_issuer_no_scheme(self): def test_invalid_issuer_bad_scheme(self): # Test an issuer with an unsupported scheme - self.configuration.api_issuer = "ftp://example.com" + self.configuration.api_issuer = "ftp://abc.fga.example" with self.assertRaises(ApiValueError): self.credentials._parse_issuer(self.configuration.api_issuer) From a431e2a37dad4f6871d117c1d8a217bbaeec12be Mon Sep 17 00:00:00 2001 From: SoulPancake Date: Fri, 17 Apr 2026 11:41:26 +0530 Subject: [PATCH 3/3] feat: address comments --- openfga_sdk/credentials.py | 4 ++-- openfga_sdk/oauth2.py | 2 +- openfga_sdk/sync/oauth2.py | 2 +- test/sync/oauth2_test.py | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/openfga_sdk/credentials.py b/openfga_sdk/credentials.py index f461adf..7470c9f 100644 --- a/openfga_sdk/credentials.py +++ b/openfga_sdk/credentials.py @@ -218,7 +218,7 @@ def validate_credentials_config(self): or none_or_empty(self.configuration.api_issuer) ): raise ApiValueError( - "configuration `{}` requires client_id, client_secret and api_issuer defined for client_credentials method." + f"configuration `{self.configuration}` requires client_id, client_secret and api_issuer defined for client_credentials method." ) # Normalize blank/whitespace values to None @@ -235,7 +235,7 @@ def validate_credentials_config(self): self.configuration.scopes = None if isinstance(self.configuration.scopes, list): self.configuration.scopes = [ - s + s.strip() for s in self.configuration.scopes if isinstance(s, str) and s.strip() ] diff --git a/openfga_sdk/oauth2.py b/openfga_sdk/oauth2.py index 2161f44..27eac5a 100644 --- a/openfga_sdk/oauth2.py +++ b/openfga_sdk/oauth2.py @@ -76,7 +76,7 @@ async def _obtain_token(self, client): # Add scope parameter if scopes are configured if configuration.scopes is not None: if isinstance(configuration.scopes, list): - scope_str = " ".join(s for s in configuration.scopes if s and s.strip()) + scope_str = " ".join(s.strip() for s in configuration.scopes if s and s.strip()) else: scope_str = ( configuration.scopes.strip() diff --git a/openfga_sdk/sync/oauth2.py b/openfga_sdk/sync/oauth2.py index 0602c33..0f5bc09 100644 --- a/openfga_sdk/sync/oauth2.py +++ b/openfga_sdk/sync/oauth2.py @@ -76,7 +76,7 @@ def _obtain_token(self, client): # Add scope parameter if scopes are configured if configuration.scopes is not None: if isinstance(configuration.scopes, list): - scope_str = " ".join(s for s in configuration.scopes if s and s.strip()) + scope_str = " ".join(s.strip() for s in configuration.scopes if s and s.strip()) else: scope_str = ( configuration.scopes.strip() diff --git a/test/sync/oauth2_test.py b/test/sync/oauth2_test.py index 27ae1f2..d0dc387 100644 --- a/test/sync/oauth2_test.py +++ b/test/sync/oauth2_test.py @@ -478,4 +478,3 @@ def test_get_authentication_with_scopes_no_audience(self, mock_request): ) rest_client.close() - rest_client.close()