Skip to content

Commit 54aeebb

Browse files
SoulPancakerhamzeh
authored andcommitted
feat(python): add OAuth2 scopes parameter support to CredentialConfiguration
This is a backport of openfga/python-sdk#213
1 parent 992ff16 commit 54aeebb

6 files changed

Lines changed: 297 additions & 2 deletions

File tree

config/clients/python/template/src/credentials.py.mustache

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class CredentialConfiguration:
1919
:param api_token: Bearer token to be sent for authentication
2020
:param api_audience: API audience used for OAuth2
2121
:param api_issuer: API issuer used for OAuth2
22+
:param scopes: OAuth2 scopes to request, can be a list of strings or a space-separated string
2223
"""
2324

2425
def __init__(
@@ -28,12 +29,14 @@ class CredentialConfiguration:
2829
api_audience: str | None = None,
2930
api_issuer: str | None = None,
3031
api_token: str | None = None,
32+
scopes: str | list[str] | None = None,
3133
):
3234
self._client_id = client_id
3335
self._client_secret = client_secret
3436
self._api_audience = api_audience
3537
self._api_issuer = api_issuer
3638
self._api_token = api_token
39+
self._scopes = scopes
3740

3841

3942
@property
@@ -106,6 +109,20 @@ class CredentialConfiguration:
106109
"""
107110
self._api_token = value
108111

112+
@property
113+
def scopes(self):
114+
"""
115+
Return the scopes configured
116+
"""
117+
return self._scopes
118+
119+
@scopes.setter
120+
def scopes(self, value):
121+
"""
122+
Update the scopes
123+
"""
124+
self._scopes = value
125+
109126

110127
class Credentials:
111128
"""

config/clients/python/template/src/oauth2.py.mustache

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ class OAuth2Client:
6969
'grant_type': "client_credentials",
7070
}
7171

72+
# Add scope parameter if scopes are configured
73+
if configuration.scopes is not None:
74+
if isinstance(configuration.scopes, list):
75+
post_params["scope"] = " ".join(configuration.scopes)
76+
else:
77+
post_params["scope"] = configuration.scopes
78+
7279
headers = urllib3.response.HTTPHeaderDict({'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'openfga-sdk (python) {{packageVersion}}'})
7380

7481
max_retry = (

config/clients/python/template/src/sync/oauth2.py.mustache

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class OAuth2Client:
6060
"""
6161
configuration = self._credentials.configuration
6262

63-
token_url = f'https://{configuration.api_issuer}/oauth/token'
63+
token_url = self._credentials._parse_issuer(configuration.api_issuer)
6464

6565
post_params = {
6666
'client_id': configuration.client_id,
@@ -69,6 +69,13 @@ class OAuth2Client:
6969
'grant_type': "client_credentials",
7070
}
7171

72+
# Add scope parameter if scopes are configured
73+
if configuration.scopes is not None:
74+
if isinstance(configuration.scopes, list):
75+
post_params["scope"] = " ".join(configuration.scopes)
76+
else:
77+
post_params["scope"] = configuration.scopes
78+
7279
headers = urllib3.response.HTTPHeaderDict({'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'openfga-sdk (python) {{packageVersion}}'})
7380

7481
max_retry = (

config/clients/python/template/test/credentials_test.py.mustache

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,42 @@ class TestCredentials(IsolatedAsyncioTestCase):
131131
with self.assertRaises(openfga_sdk.ApiValueError):
132132
credential.validate_credentials_config()
133133

134+
def test_configuration_client_credentials_with_scopes_list(self):
135+
"""
136+
Test credential with method client_credentials and scopes as list is valid
137+
"""
138+
credential = Credentials(
139+
method="client_credentials",
140+
configuration=CredentialConfiguration(
141+
client_id="myclientid",
142+
client_secret="mysecret",
143+
api_issuer="issuer.{{sampleApiDomain}}",
144+
api_audience="myaudience",
145+
scopes=["read", "write", "admin"],
146+
),
147+
)
148+
credential.validate_credentials_config()
149+
self.assertEqual(credential.method, "client_credentials")
150+
self.assertEqual(credential.configuration.scopes, ["read", "write", "admin"])
151+
152+
def test_configuration_client_credentials_with_scopes_string(self):
153+
"""
154+
Test credential with method client_credentials and scopes as string is valid
155+
"""
156+
credential = Credentials(
157+
method="client_credentials",
158+
configuration=CredentialConfiguration(
159+
client_id="myclientid",
160+
client_secret="mysecret",
161+
api_issuer="issuer.{{sampleApiDomain}}",
162+
api_audience="myaudience",
163+
scopes="read write admin",
164+
),
165+
)
166+
credential.validate_credentials_config()
167+
self.assertEqual(credential.method, "client_credentials")
168+
self.assertEqual(credential.configuration.scopes, "read write admin")
169+
134170

135171
class TestCredentialsIssuer(IsolatedAsyncioTestCase):
136172
def setUp(self):

config/clients/python/template/test/oauth2_test.py.mustache

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,3 +484,117 @@ This is not a JSON response
484484
},
485485
)
486486
await rest_client.close()
487+
488+
@patch.object(rest.RESTClientObject, "request")
489+
async def test_get_authentication_obtain_client_credentials_with_scopes_list(self, mock_request):
490+
"""
491+
Test getting authentication header when method is client credentials with scopes as list
492+
"""
493+
response_body = """
494+
{
495+
"expires_in": 120,
496+
"access_token": "AABBCCDD"
497+
}
498+
"""
499+
mock_request.return_value = mock_response(response_body, 200)
500+
501+
credentials = Credentials(
502+
method="client_credentials",
503+
configuration=CredentialConfiguration(
504+
client_id="myclientid",
505+
client_secret="mysecret",
506+
api_issuer="issuer.fga.example",
507+
api_audience="myaudience",
508+
scopes=["read", "write", "admin"],
509+
),
510+
)
511+
rest_client = rest.RESTClientObject(Configuration())
512+
current_time = datetime.now()
513+
client = OAuth2Client(credentials)
514+
auth_header = await client.get_authentication_header(rest_client)
515+
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
516+
self.assertEqual(client._access_token, "AABBCCDD")
517+
self.assertGreaterEqual(
518+
client._access_expiry_time, current_time + timedelta(seconds=120)
519+
)
520+
expected_header = urllib3.response.HTTPHeaderDict(
521+
{
522+
"Accept": "application/json",
523+
"Content-Type": "application/x-www-form-urlencoded",
524+
"User-Agent": "openfga-sdk (python) {{packageVersion}}",
525+
}
526+
)
527+
mock_request.assert_called_once_with(
528+
method="POST",
529+
url="https://issuer.fga.example/oauth/token",
530+
headers=expected_header,
531+
query_params=None,
532+
body=None,
533+
_preload_content=True,
534+
_request_timeout=None,
535+
post_params={
536+
"client_id": "myclientid",
537+
"client_secret": "mysecret",
538+
"audience": "myaudience",
539+
"grant_type": "client_credentials",
540+
"scope": "read write admin",
541+
},
542+
)
543+
await rest_client.close()
544+
545+
@patch.object(rest.RESTClientObject, "request")
546+
async def test_get_authentication_obtain_client_credentials_with_scopes_string(self, mock_request):
547+
"""
548+
Test getting authentication header when method is client credentials with scopes as string
549+
"""
550+
response_body = """
551+
{
552+
"expires_in": 120,
553+
"access_token": "AABBCCDD"
554+
}
555+
"""
556+
mock_request.return_value = mock_response(response_body, 200)
557+
558+
credentials = Credentials(
559+
method="client_credentials",
560+
configuration=CredentialConfiguration(
561+
client_id="myclientid",
562+
client_secret="mysecret",
563+
api_issuer="issuer.fga.example",
564+
api_audience="myaudience",
565+
scopes="read write admin",
566+
),
567+
)
568+
rest_client = rest.RESTClientObject(Configuration())
569+
current_time = datetime.now()
570+
client = OAuth2Client(credentials)
571+
auth_header = await client.get_authentication_header(rest_client)
572+
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
573+
self.assertEqual(client._access_token, "AABBCCDD")
574+
self.assertGreaterEqual(
575+
client._access_expiry_time, current_time + timedelta(seconds=120)
576+
)
577+
expected_header = urllib3.response.HTTPHeaderDict(
578+
{
579+
"Accept": "application/json",
580+
"Content-Type": "application/x-www-form-urlencoded",
581+
"User-Agent": "openfga-sdk (python) {{packageVersion}}",
582+
}
583+
)
584+
mock_request.assert_called_once_with(
585+
method="POST",
586+
url="https://issuer.fga.example/oauth/token",
587+
headers=expected_header,
588+
query_params=None,
589+
body=None,
590+
_preload_content=True,
591+
_request_timeout=None,
592+
post_params={
593+
"client_id": "myclientid",
594+
"client_secret": "mysecret",
595+
"audience": "myaudience",
596+
"grant_type": "client_credentials",
597+
"scope": "read write admin",
598+
},
599+
)
600+
await rest_client.close()

config/clients/python/template/test/sync/oauth2_test.py.mustache

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ This is not a JSON response
212212
rest_client.close()
213213

214214
@patch.object(rest.RESTClientObject, "request")
215-
async def test_get_authentication_retries_5xx_responses(self, mock_request):
215+
def test_get_authentication_retries_5xx_responses(self, mock_request):
216216
"""
217217
Receiving a 5xx response from the server should be retried
218218
"""
@@ -262,3 +262,117 @@ This is not a JSON response
262262
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
263263

264264
rest_client.close()
265+
266+
@patch.object(rest.RESTClientObject, "request")
267+
def test_get_authentication_obtain_client_credentials_with_scopes_list(self, mock_request):
268+
"""
269+
Test getting authentication header when method is client credentials with scopes as list
270+
"""
271+
response_body = """
272+
{
273+
"expires_in": 120,
274+
"access_token": "AABBCCDD"
275+
}
276+
"""
277+
mock_request.return_value = mock_response(response_body, 200)
278+
279+
credentials = Credentials(
280+
method="client_credentials",
281+
configuration=CredentialConfiguration(
282+
client_id="myclientid",
283+
client_secret="mysecret",
284+
api_issuer="issuer.fga.example",
285+
api_audience="myaudience",
286+
scopes=["read", "write", "admin"],
287+
),
288+
)
289+
rest_client = rest.RESTClientObject(Configuration())
290+
current_time = datetime.now()
291+
client = OAuth2Client(credentials)
292+
auth_header = client.get_authentication_header(rest_client)
293+
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
294+
self.assertEqual(client._access_token, "AABBCCDD")
295+
self.assertGreaterEqual(
296+
client._access_expiry_time, current_time + timedelta(seconds=120)
297+
)
298+
expected_header = urllib3.response.HTTPHeaderDict(
299+
{
300+
"Accept": "application/json",
301+
"Content-Type": "application/x-www-form-urlencoded",
302+
"User-Agent": "openfga-sdk (python) {{packageVersion}}",
303+
}
304+
)
305+
mock_request.assert_called_once_with(
306+
method="POST",
307+
url="https://issuer.fga.example/oauth/token",
308+
headers=expected_header,
309+
query_params=None,
310+
body=None,
311+
_preload_content=True,
312+
_request_timeout=None,
313+
post_params={
314+
"client_id": "myclientid",
315+
"client_secret": "mysecret",
316+
"audience": "myaudience",
317+
"grant_type": "client_credentials",
318+
"scope": "read write admin",
319+
},
320+
)
321+
rest_client.close()
322+
323+
@patch.object(rest.RESTClientObject, "request")
324+
def test_get_authentication_obtain_client_credentials_with_scopes_string(self, mock_request):
325+
"""
326+
Test getting authentication header when method is client credentials with scopes as string
327+
"""
328+
response_body = """
329+
{
330+
"expires_in": 120,
331+
"access_token": "AABBCCDD"
332+
}
333+
"""
334+
mock_request.return_value = mock_response(response_body, 200)
335+
336+
credentials = Credentials(
337+
method="client_credentials",
338+
configuration=CredentialConfiguration(
339+
client_id="myclientid",
340+
client_secret="mysecret",
341+
api_issuer="issuer.fga.example",
342+
api_audience="myaudience",
343+
scopes="read write admin",
344+
),
345+
)
346+
rest_client = rest.RESTClientObject(Configuration())
347+
current_time = datetime.now()
348+
client = OAuth2Client(credentials)
349+
auth_header = client.get_authentication_header(rest_client)
350+
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
351+
self.assertEqual(client._access_token, "AABBCCDD")
352+
self.assertGreaterEqual(
353+
client._access_expiry_time, current_time + timedelta(seconds=120)
354+
)
355+
expected_header = urllib3.response.HTTPHeaderDict(
356+
{
357+
"Accept": "application/json",
358+
"Content-Type": "application/x-www-form-urlencoded",
359+
"User-Agent": "openfga-sdk (python) {{packageVersion}}",
360+
}
361+
)
362+
mock_request.assert_called_once_with(
363+
method="POST",
364+
url="https://issuer.fga.example/oauth/token",
365+
headers=expected_header,
366+
query_params=None,
367+
body=None,
368+
_preload_content=True,
369+
_request_timeout=None,
370+
post_params={
371+
"client_id": "myclientid",
372+
"client_secret": "mysecret",
373+
"audience": "myaudience",
374+
"grant_type": "client_credentials",
375+
"scope": "read write admin",
376+
},
377+
)
378+
rest_client.close()

0 commit comments

Comments
 (0)