Skip to content

Commit 89a39d1

Browse files
authored
feat: add OAuth2 scopes parameter support to CredentialConfiguration (#213)
* feat: add OAuth2 scopes parameter support to CredentialConfiguration * feat: add async tests * fix: tests * feat: run ruff lint fix
1 parent 1b0ac3e commit 89a39d1

File tree

6 files changed

+294
-0
lines changed

6 files changed

+294
-0
lines changed

openfga_sdk/credentials.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class CredentialConfiguration:
3030
:param api_token: Bearer token to be sent for authentication
3131
:param api_audience: API audience used for OAuth2
3232
:param api_issuer: API issuer used for OAuth2
33+
:param scopes: OAuth2 scopes to request, can be a list of strings or a space-separated string
3334
"""
3435

3536
def __init__(
@@ -39,12 +40,14 @@ def __init__(
3940
api_audience: str | None = None,
4041
api_issuer: str | None = None,
4142
api_token: str | None = None,
43+
scopes: str | list[str] | None = None,
4244
):
4345
self._client_id = client_id
4446
self._client_secret = client_secret
4547
self._api_audience = api_audience
4648
self._api_issuer = api_issuer
4749
self._api_token = api_token
50+
self._scopes = scopes
4851

4952
@property
5053
def client_id(self):
@@ -116,6 +119,20 @@ def api_token(self, value):
116119
"""
117120
self._api_token = value
118121

122+
@property
123+
def scopes(self):
124+
"""
125+
Return the scopes configured
126+
"""
127+
return self._scopes
128+
129+
@scopes.setter
130+
def scopes(self, value):
131+
"""
132+
Update the scopes
133+
"""
134+
self._scopes = value
135+
119136

120137
class Credentials:
121138
"""

openfga_sdk/oauth2.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ async def _obtain_token(self, client):
7979
"grant_type": "client_credentials",
8080
}
8181

82+
# Add scope parameter if scopes are configured
83+
if configuration.scopes is not None:
84+
if isinstance(configuration.scopes, list):
85+
post_params["scope"] = " ".join(configuration.scopes)
86+
else:
87+
post_params["scope"] = configuration.scopes
88+
8289
headers = urllib3.response.HTTPHeaderDict(
8390
{
8491
"Accept": "application/json",

openfga_sdk/sync/oauth2.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ def _obtain_token(self, client):
7979
"grant_type": "client_credentials",
8080
}
8181

82+
# Add scope parameter if scopes are configured
83+
if configuration.scopes is not None:
84+
if isinstance(configuration.scopes, list):
85+
post_params["scope"] = " ".join(configuration.scopes)
86+
else:
87+
post_params["scope"] = configuration.scopes
88+
8289
headers = urllib3.response.HTTPHeaderDict(
8390
{
8491
"Accept": "application/json",

test/credentials_test.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,42 @@ def test_configuration_client_credentials(self):
107107
credential.validate_credentials_config()
108108
self.assertEqual(credential.method, "client_credentials")
109109

110+
def test_configuration_client_credentials_with_scopes_list(self):
111+
"""
112+
Test credential with method client_credentials and scopes as list is valid
113+
"""
114+
credential = Credentials(
115+
method="client_credentials",
116+
configuration=CredentialConfiguration(
117+
client_id="myclientid",
118+
client_secret="mysecret",
119+
api_issuer="issuer.fga.example",
120+
api_audience="myaudience",
121+
scopes=["read", "write", "admin"],
122+
),
123+
)
124+
credential.validate_credentials_config()
125+
self.assertEqual(credential.method, "client_credentials")
126+
self.assertEqual(credential.configuration.scopes, ["read", "write", "admin"])
127+
128+
def test_configuration_client_credentials_with_scopes_string(self):
129+
"""
130+
Test credential with method client_credentials and scopes as string is valid
131+
"""
132+
credential = Credentials(
133+
method="client_credentials",
134+
configuration=CredentialConfiguration(
135+
client_id="myclientid",
136+
client_secret="mysecret",
137+
api_issuer="issuer.fga.example",
138+
api_audience="myaudience",
139+
scopes="read write admin",
140+
),
141+
)
142+
credential.validate_credentials_config()
143+
self.assertEqual(credential.method, "client_credentials")
144+
self.assertEqual(credential.configuration.scopes, "read write admin")
145+
110146
def test_configuration_client_credentials_missing_config(self):
111147
"""
112148
Test credential with method client_credentials and configuration is missing

test/oauth2_test.py

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

test/sync/oauth2_test.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,119 @@ def test_get_authentication_obtain_client_credentials(self, mock_request):
104104
)
105105
rest_client.close()
106106

107+
@patch.object(rest.RESTClientObject, "request")
108+
def test_get_authentication_obtain_client_credentials_with_scopes_list(self, mock_request):
109+
"""
110+
Test getting authentication header when method is client credentials with scopes as list
111+
"""
112+
response_body = """
113+
{
114+
"expires_in": 120,
115+
"access_token": "AABBCCDD"
116+
}
117+
"""
118+
mock_request.return_value = mock_response(response_body, 200)
119+
120+
credentials = Credentials(
121+
method="client_credentials",
122+
configuration=CredentialConfiguration(
123+
client_id="myclientid",
124+
client_secret="mysecret",
125+
api_issuer="issuer.fga.example",
126+
api_audience="myaudience",
127+
scopes=["read", "write", "admin"],
128+
),
129+
)
130+
rest_client = rest.RESTClientObject(Configuration())
131+
current_time = datetime.now()
132+
client = OAuth2Client(credentials)
133+
auth_header = client.get_authentication_header(rest_client)
134+
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
135+
self.assertEqual(client._access_token, "AABBCCDD")
136+
self.assertGreaterEqual(
137+
client._access_expiry_time, current_time + timedelta(seconds=120)
138+
)
139+
expected_header = urllib3.response.HTTPHeaderDict(
140+
{
141+
"Accept": "application/json",
142+
"Content-Type": "application/x-www-form-urlencoded",
143+
"User-Agent": "openfga-sdk (python) 0.9.5",
144+
}
145+
)
146+
mock_request.assert_called_once_with(
147+
method="POST",
148+
url="https://issuer.fga.example/oauth/token",
149+
headers=expected_header,
150+
query_params=None,
151+
body=None,
152+
_preload_content=True,
153+
_request_timeout=None,
154+
post_params={
155+
"client_id": "myclientid",
156+
"client_secret": "mysecret",
157+
"audience": "myaudience",
158+
"grant_type": "client_credentials",
159+
"scope": "read write admin",
160+
},
161+
)
162+
rest_client.close()
163+
164+
@patch.object(rest.RESTClientObject, "request")
165+
def test_get_authentication_obtain_client_credentials_with_scopes_string(self, mock_request):
166+
"""
167+
Test getting authentication header when method is client credentials with scopes as string
168+
"""
169+
response_body = """
170+
{
171+
"expires_in": 120,
172+
"access_token": "AABBCCDD"
173+
}
174+
"""
175+
mock_request.return_value = mock_response(response_body, 200)
176+
177+
credentials = Credentials(
178+
method="client_credentials",
179+
configuration=CredentialConfiguration(
180+
client_id="myclientid",
181+
client_secret="mysecret",
182+
api_issuer="issuer.fga.example",
183+
api_audience="myaudience",
184+
scopes="read write admin",
185+
),
186+
)
187+
rest_client = rest.RESTClientObject(Configuration())
188+
current_time = datetime.now()
189+
client = OAuth2Client(credentials)
190+
auth_header = client.get_authentication_header(rest_client)
191+
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
192+
self.assertEqual(client._access_token, "AABBCCDD")
193+
self.assertGreaterEqual(
194+
client._access_expiry_time, current_time + timedelta(seconds=120)
195+
)
196+
expected_header = urllib3.response.HTTPHeaderDict(
197+
{
198+
"Accept": "application/json",
199+
"Content-Type": "application/x-www-form-urlencoded",
200+
"User-Agent": "openfga-sdk (python) 0.9.5",
201+
}
202+
)
203+
mock_request.assert_called_once_with(
204+
method="POST",
205+
url="https://issuer.fga.example/oauth/token",
206+
headers=expected_header,
207+
query_params=None,
208+
body=None,
209+
_preload_content=True,
210+
_request_timeout=None,
211+
post_params={
212+
"client_id": "myclientid",
213+
"client_secret": "mysecret",
214+
"audience": "myaudience",
215+
"grant_type": "client_credentials",
216+
"scope": "read write admin",
217+
},
218+
)
219+
107220
@patch.object(rest.RESTClientObject, "request")
108221
def test_get_authentication_obtain_client_credentials_failed(self, mock_request):
109222
"""

0 commit comments

Comments
 (0)