Skip to content

Commit bf589cf

Browse files
committed
feat: add OAuth2 scopes parameter support to CredentialConfiguration
1 parent 1b0ac3e commit bf589cf

5 files changed

Lines changed: 180 additions & 0 deletions

File tree

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/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)