Skip to content

Commit 64f9b9f

Browse files
authored
feat: oauth2 scopes for authentication (#276)
* feat: oauth2 scopes for authn * feat: add blank.whitespace handling + docs * feat: address comments
1 parent 63befb8 commit 64f9b9f

7 files changed

Lines changed: 393 additions & 19 deletions

File tree

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,10 @@ async def main():
189189
method='client_credentials',
190190
configuration=CredentialConfiguration(
191191
api_issuer=FGA_API_TOKEN_ISSUER,
192-
api_audience=FGA_API_AUDIENCE,
192+
api_audience=FGA_API_AUDIENCE, # optional, required for Auth0; omit for standard OAuth2
193193
client_id=FGA_CLIENT_ID,
194194
client_secret=FGA_CLIENT_SECRET,
195+
# scopes="read write", # optional, space-separated OAuth2 scopes
195196
)
196197
)
197198
)
@@ -201,6 +202,37 @@ async def main():
201202
return api_response
202203
```
203204

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+
204236
### Custom Headers
205237

206238
#### Default Headers

openfga_sdk/credentials.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,12 +215,32 @@ def validate_credentials_config(self):
215215
self.configuration is None
216216
or none_or_empty(self.configuration.client_id)
217217
or none_or_empty(self.configuration.client_secret)
218-
or none_or_empty(self.configuration.api_audience)
219218
or none_or_empty(self.configuration.api_issuer)
220219
):
221220
raise ApiValueError(
222-
"configuration `{}` requires client_id, client_secret, api_audience and api_issuer defined for client_credentials method."
221+
f"configuration `{self.configuration}` requires client_id, client_secret and api_issuer defined for client_credentials method."
223222
)
224223

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.strip()
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+
225245
# validate token issuer
226246
self._parse_issuer(self.configuration.api_issuer)

openfga_sdk/oauth2.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,27 @@ async def _obtain_token(self, client):
6464
post_params = {
6565
"client_id": configuration.client_id,
6666
"client_secret": configuration.client_secret,
67-
"audience": configuration.api_audience,
6867
"grant_type": "client_credentials",
6968
}
7069

70+
if (
71+
configuration.api_audience is not None
72+
and configuration.api_audience.strip()
73+
):
74+
post_params["audience"] = configuration.api_audience
75+
7176
# Add scope parameter if scopes are configured
7277
if configuration.scopes is not None:
7378
if isinstance(configuration.scopes, list):
74-
post_params["scope"] = " ".join(configuration.scopes)
79+
scope_str = " ".join(s.strip() for s in configuration.scopes if s and s.strip())
7580
else:
76-
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
7788

7889
headers = urllib3.response.HTTPHeaderDict(
7990
{

openfga_sdk/sync/oauth2.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,27 @@ def _obtain_token(self, client):
6464
post_params = {
6565
"client_id": configuration.client_id,
6666
"client_secret": configuration.client_secret,
67-
"audience": configuration.api_audience,
6867
"grant_type": "client_credentials",
6968
}
7069

70+
if (
71+
configuration.api_audience is not None
72+
and configuration.api_audience.strip()
73+
):
74+
post_params["audience"] = configuration.api_audience
75+
7176
# Add scope parameter if scopes are configured
7277
if configuration.scopes is not None:
7378
if isinstance(configuration.scopes, list):
74-
post_params["scope"] = " ".join(configuration.scopes)
79+
scope_str = " ".join(s.strip() for s in configuration.scopes if s and s.strip())
7580
else:
76-
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
7788

7889
headers = urllib3.response.HTTPHeaderDict(
7990
{

test/credentials_test.py

Lines changed: 111 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,10 @@ def test_configuration_client_credentials_missing_api_issuer(self):
184184
with self.assertRaises(openfga_sdk.ApiValueError):
185185
credential.validate_credentials_config()
186186

187-
def test_configuration_client_credentials_missing_api_audience(self):
187+
def test_configuration_client_credentials_without_api_audience(self):
188188
"""
189-
Test credential with method client_credentials and configuration is missing api audience
189+
Test credential with method client_credentials and no api_audience is valid
190+
(audience is optional for standard OAuth2 servers)
190191
"""
191192
credential = Credentials(
192193
method="client_credentials",
@@ -196,14 +197,114 @@ def test_configuration_client_credentials_missing_api_audience(self):
196197
api_issuer="issuer.fga.example",
197198
),
198199
)
199-
with self.assertRaises(openfga_sdk.ApiValueError):
200-
credential.validate_credentials_config()
200+
credential.validate_credentials_config()
201+
self.assertEqual(credential.method, "client_credentials")
202+
self.assertIsNone(credential.configuration.api_audience)
203+
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)
201300

202301

203302
class TestCredentialsIssuer(IsolatedAsyncioTestCase):
204303
def setUp(self):
205304
# Setup a basic configuration that can be modified per test case
206-
self.configuration = CredentialConfiguration(api_issuer="https://example.com")
305+
self.configuration = CredentialConfiguration(
306+
api_issuer="https://abc.fga.example"
307+
)
207308
self.credentials = Credentials(
208309
method="client_credentials", configuration=self.configuration
209310
)
@@ -216,15 +317,15 @@ def test_valid_issuer_https(self):
216317

217318
def test_valid_issuer_with_oauth_endpoint_https(self):
218319
# Test a valid HTTPS URL
219-
self.configuration.api_issuer = "https://example.com/oauth/token"
320+
self.configuration.api_issuer = "https://abc.fga.example/oauth/token"
220321
result = self.credentials._parse_issuer(self.configuration.api_issuer)
221-
self.assertEqual(result, "https://example.com/oauth/token")
322+
self.assertEqual(result, "https://abc.fga.example/oauth/token")
222323

223324
def test_valid_issuer_with_some_endpoint_https(self):
224325
# Test a valid HTTPS URL
225-
self.configuration.api_issuer = "https://example.com/oauth/some/endpoint"
326+
self.configuration.api_issuer = "https://abc.fga.example/oauth/some/endpoint"
226327
result = self.credentials._parse_issuer(self.configuration.api_issuer)
227-
self.assertEqual(result, "https://example.com/oauth/some/endpoint")
328+
self.assertEqual(result, "https://abc.fga.example/oauth/some/endpoint")
228329

229330
def test_valid_issuer_http(self):
230331
# Test a valid HTTP URL
@@ -242,7 +343,7 @@ def test_invalid_issuer_no_scheme(self):
242343

243344
def test_invalid_issuer_bad_scheme(self):
244345
# Test an issuer with an unsupported scheme
245-
self.configuration.api_issuer = "ftp://example.com"
346+
self.configuration.api_issuer = "ftp://abc.fga.example"
246347
with self.assertRaises(ApiValueError):
247348
self.credentials._parse_issuer(self.configuration.api_issuer)
248349

0 commit comments

Comments
 (0)