Skip to content

Commit ad6a453

Browse files
committed
feat: add blank.whitespace handling + docs
1 parent bc2c2b1 commit ad6a453

File tree

5 files changed

+181
-12
lines changed

5 files changed

+181
-12
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,37 @@ async def main():
202202
return api_response
203203
```
204204

205+
> **Note:** `api_issuer` accepts either a hostname (e.g., `issuer.fga.example`, which defaults to `https://<hostname>/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.
206+
207+
#### OAuth2 Client Credentials (Standard OAuth2)
208+
209+
For OAuth2 providers that use `scope` instead of `audience`:
210+
211+
```python
212+
from openfga_sdk import ClientConfiguration, OpenFgaClient
213+
from openfga_sdk.credentials import Credentials, CredentialConfiguration
214+
215+
216+
async def main():
217+
configuration = ClientConfiguration(
218+
api_url=FGA_API_URL, # required
219+
store_id=FGA_STORE_ID, # optional
220+
authorization_model_id=FGA_MODEL_ID, # optional
221+
credentials=Credentials(
222+
method='client_credentials',
223+
configuration=CredentialConfiguration(
224+
api_issuer="https://oauth.fga.example/token", # full token endpoint URL
225+
client_id=FGA_CLIENT_ID,
226+
client_secret=FGA_CLIENT_SECRET,
227+
scopes="email profile", # space-separated OAuth2 scopes
228+
)
229+
)
230+
)
231+
async with OpenFgaClient(configuration) as fga_client:
232+
api_response = await fga_client.read_authorization_models()
233+
return api_response
234+
```
235+
205236
### Custom Headers
206237

207238
#### Default Headers

openfga_sdk/credentials.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,5 +221,26 @@ def validate_credentials_config(self):
221221
"configuration `{}` requires client_id, client_secret and api_issuer defined for client_credentials method."
222222
)
223223

224+
# Normalize blank/whitespace values to None
225+
# (common misconfiguration from env vars like FGA_API_AUDIENCE="")
226+
if (
227+
isinstance(self.configuration.api_audience, str)
228+
and self.configuration.api_audience.strip() == ""
229+
):
230+
self.configuration.api_audience = None
231+
if (
232+
isinstance(self.configuration.scopes, str)
233+
and self.configuration.scopes.strip() == ""
234+
):
235+
self.configuration.scopes = None
236+
if isinstance(self.configuration.scopes, list):
237+
self.configuration.scopes = [
238+
s
239+
for s in self.configuration.scopes
240+
if isinstance(s, str) and s.strip()
241+
]
242+
if not self.configuration.scopes:
243+
self.configuration.scopes = None
244+
224245
# validate token issuer
225246
self._parse_issuer(self.configuration.api_issuer)

openfga_sdk/oauth2.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,24 @@ async def _obtain_token(self, client):
6767
"grant_type": "client_credentials",
6868
}
6969

70-
if configuration.api_audience is not None:
70+
if (
71+
configuration.api_audience is not None
72+
and configuration.api_audience.strip()
73+
):
7174
post_params["audience"] = configuration.api_audience
7275

7376
# Add scope parameter if scopes are configured
7477
if configuration.scopes is not None:
7578
if isinstance(configuration.scopes, list):
76-
post_params["scope"] = " ".join(configuration.scopes)
79+
scope_str = " ".join(s for s in configuration.scopes if s and s.strip())
7780
else:
78-
post_params["scope"] = configuration.scopes
81+
scope_str = (
82+
configuration.scopes.strip()
83+
if isinstance(configuration.scopes, str)
84+
else ""
85+
)
86+
if scope_str:
87+
post_params["scope"] = scope_str
7988

8089
headers = urllib3.response.HTTPHeaderDict(
8190
{

openfga_sdk/sync/oauth2.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,24 @@ def _obtain_token(self, client):
6767
"grant_type": "client_credentials",
6868
}
6969

70-
if configuration.api_audience is not None:
70+
if (
71+
configuration.api_audience is not None
72+
and configuration.api_audience.strip()
73+
):
7174
post_params["audience"] = configuration.api_audience
7275

7376
# Add scope parameter if scopes are configured
7477
if configuration.scopes is not None:
7578
if isinstance(configuration.scopes, list):
76-
post_params["scope"] = " ".join(configuration.scopes)
79+
scope_str = " ".join(s for s in configuration.scopes if s and s.strip())
7780
else:
78-
post_params["scope"] = configuration.scopes
81+
scope_str = (
82+
configuration.scopes.strip()
83+
if isinstance(configuration.scopes, str)
84+
else ""
85+
)
86+
if scope_str:
87+
post_params["scope"] = scope_str
7988

8089
headers = urllib3.response.HTTPHeaderDict(
8190
{

test/credentials_test.py

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,110 @@ def test_configuration_client_credentials_without_api_audience(self):
201201
self.assertEqual(credential.method, "client_credentials")
202202
self.assertIsNone(credential.configuration.api_audience)
203203

204+
def test_configuration_client_credentials_blank_api_audience_normalized(self):
205+
"""
206+
Test that blank/whitespace api_audience is normalized to None
207+
(common misconfiguration from env vars like FGA_API_AUDIENCE="")
208+
"""
209+
credential = Credentials(
210+
method="client_credentials",
211+
configuration=CredentialConfiguration(
212+
client_id="myclientid",
213+
client_secret="mysecret",
214+
api_issuer="issuer.fga.example",
215+
api_audience="",
216+
),
217+
)
218+
credential.validate_credentials_config()
219+
self.assertIsNone(credential.configuration.api_audience)
220+
221+
def test_configuration_client_credentials_whitespace_api_audience_normalized(self):
222+
"""
223+
Test that whitespace-only api_audience is normalized to None
224+
"""
225+
credential = Credentials(
226+
method="client_credentials",
227+
configuration=CredentialConfiguration(
228+
client_id="myclientid",
229+
client_secret="mysecret",
230+
api_issuer="issuer.fga.example",
231+
api_audience=" ",
232+
),
233+
)
234+
credential.validate_credentials_config()
235+
self.assertIsNone(credential.configuration.api_audience)
236+
237+
def test_configuration_client_credentials_blank_scopes_normalized(self):
238+
"""
239+
Test that blank scopes string is normalized to None
240+
"""
241+
credential = Credentials(
242+
method="client_credentials",
243+
configuration=CredentialConfiguration(
244+
client_id="myclientid",
245+
client_secret="mysecret",
246+
api_issuer="issuer.fga.example",
247+
scopes="",
248+
),
249+
)
250+
credential.validate_credentials_config()
251+
self.assertIsNone(credential.configuration.scopes)
252+
253+
def test_configuration_client_credentials_whitespace_scopes_normalized(self):
254+
"""
255+
Test that whitespace-only scopes string is normalized to None
256+
"""
257+
credential = Credentials(
258+
method="client_credentials",
259+
configuration=CredentialConfiguration(
260+
client_id="myclientid",
261+
client_secret="mysecret",
262+
api_issuer="issuer.fga.example",
263+
scopes=" ",
264+
),
265+
)
266+
credential.validate_credentials_config()
267+
self.assertIsNone(credential.configuration.scopes)
268+
269+
def test_configuration_client_credentials_empty_scopes_list_normalized(self):
270+
"""
271+
Test that empty scopes list is normalized to None
272+
"""
273+
credential = Credentials(
274+
method="client_credentials",
275+
configuration=CredentialConfiguration(
276+
client_id="myclientid",
277+
client_secret="mysecret",
278+
api_issuer="issuer.fga.example",
279+
scopes=[],
280+
),
281+
)
282+
credential.validate_credentials_config()
283+
self.assertIsNone(credential.configuration.scopes)
284+
285+
def test_configuration_client_credentials_blank_scopes_list_normalized(self):
286+
"""
287+
Test that scopes list with only blank strings is normalized to None
288+
"""
289+
credential = Credentials(
290+
method="client_credentials",
291+
configuration=CredentialConfiguration(
292+
client_id="myclientid",
293+
client_secret="mysecret",
294+
api_issuer="issuer.fga.example",
295+
scopes=["", " "],
296+
),
297+
)
298+
credential.validate_credentials_config()
299+
self.assertIsNone(credential.configuration.scopes)
300+
204301

205302
class TestCredentialsIssuer(IsolatedAsyncioTestCase):
206303
def setUp(self):
207304
# Setup a basic configuration that can be modified per test case
208-
self.configuration = CredentialConfiguration(api_issuer="https://example.com")
305+
self.configuration = CredentialConfiguration(
306+
api_issuer="https://abc.fga.example"
307+
)
209308
self.credentials = Credentials(
210309
method="client_credentials", configuration=self.configuration
211310
)
@@ -218,15 +317,15 @@ def test_valid_issuer_https(self):
218317

219318
def test_valid_issuer_with_oauth_endpoint_https(self):
220319
# Test a valid HTTPS URL
221-
self.configuration.api_issuer = "https://example.com/oauth/token"
320+
self.configuration.api_issuer = "https://abc.fga.example/oauth/token"
222321
result = self.credentials._parse_issuer(self.configuration.api_issuer)
223-
self.assertEqual(result, "https://example.com/oauth/token")
322+
self.assertEqual(result, "https://abc.fga.example/oauth/token")
224323

225324
def test_valid_issuer_with_some_endpoint_https(self):
226325
# Test a valid HTTPS URL
227-
self.configuration.api_issuer = "https://example.com/oauth/some/endpoint"
326+
self.configuration.api_issuer = "https://abc.fga.example/oauth/some/endpoint"
228327
result = self.credentials._parse_issuer(self.configuration.api_issuer)
229-
self.assertEqual(result, "https://example.com/oauth/some/endpoint")
328+
self.assertEqual(result, "https://abc.fga.example/oauth/some/endpoint")
230329

231330
def test_valid_issuer_http(self):
232331
# Test a valid HTTP URL
@@ -244,7 +343,7 @@ def test_invalid_issuer_no_scheme(self):
244343

245344
def test_invalid_issuer_bad_scheme(self):
246345
# Test an issuer with an unsupported scheme
247-
self.configuration.api_issuer = "ftp://example.com"
346+
self.configuration.api_issuer = "ftp://abc.fga.example"
248347
with self.assertRaises(ApiValueError):
249348
self.credentials._parse_issuer(self.configuration.api_issuer)
250349

0 commit comments

Comments
 (0)