diff --git a/README.md b/README.md index a162648..ae1381e 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 ) ) ) @@ -201,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 5cae254..7470c9f 100644 --- a/openfga_sdk/credentials.py +++ b/openfga_sdk/credentials.py @@ -215,12 +215,32 @@ 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." + f"configuration `{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.strip() + 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 61b6aab..27eac5a 100644 --- a/openfga_sdk/oauth2.py +++ b/openfga_sdk/oauth2.py @@ -64,16 +64,27 @@ 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 + 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.strip() 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 b870739..0f5bc09 100644 --- a/openfga_sdk/sync/oauth2.py +++ b/openfga_sdk/sync/oauth2.py @@ -64,16 +64,27 @@ 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 + 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.strip() 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 9cf5dcf..4c2b5d6 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,14 +197,114 @@ 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) + + 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 ) @@ -216,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 @@ -242,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) 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..d0dc387 100644 --- a/test/sync/oauth2_test.py +++ b/test/sync/oauth2_test.py @@ -378,4 +378,103 @@ 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() +