Skip to content

Commit bc2c2b1

Browse files
committed
feat: oauth2 scopes for authn
1 parent 590a3f2 commit bc2c2b1

File tree

7 files changed

+215
-9
lines changed

7 files changed

+215
-9
lines changed

README.md

Lines changed: 2 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
)

openfga_sdk/credentials.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,10 @@ 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+
"configuration `{}` requires client_id, client_secret and api_issuer defined for client_credentials method."
223222
)
224223

225224
# validate token issuer

openfga_sdk/oauth2.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,12 @@ 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 configuration.api_audience is not None:
71+
post_params["audience"] = configuration.api_audience
72+
7173
# Add scope parameter if scopes are configured
7274
if configuration.scopes is not None:
7375
if isinstance(configuration.scopes, list):

openfga_sdk/sync/oauth2.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,12 @@ 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 configuration.api_audience is not None:
71+
post_params["audience"] = configuration.api_audience
72+
7173
# Add scope parameter if scopes are configured
7274
if configuration.scopes is not None:
7375
if isinstance(configuration.scopes, list):

test/credentials_test.py

Lines changed: 6 additions & 4 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,8 +197,9 @@ 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)
201203

202204

203205
class TestCredentialsIssuer(IsolatedAsyncioTestCase):

test/oauth2_test.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,3 +601,103 @@ async def test_get_authentication_obtain_client_credentials_with_scopes_string(
601601
},
602602
)
603603
await rest_client.close()
604+
605+
@patch.object(rest.RESTClientObject, "request")
606+
async def test_get_authentication_without_audience(self, mock_request):
607+
"""
608+
Test that audience is omitted from the token request when not provided
609+
(standard OAuth2 flow without Auth0 audience extension)
610+
"""
611+
response_body = """
612+
{
613+
"expires_in": 120,
614+
"access_token": "AABBCCDD"
615+
}
616+
"""
617+
mock_request.return_value = mock_response(response_body, 200)
618+
619+
credentials = Credentials(
620+
method="client_credentials",
621+
configuration=CredentialConfiguration(
622+
client_id="myclientid",
623+
client_secret="mysecret",
624+
api_issuer="issuer.fga.example",
625+
),
626+
)
627+
rest_client = rest.RESTClientObject(Configuration())
628+
client = OAuth2Client(credentials)
629+
auth_header = await client.get_authentication_header(rest_client)
630+
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
631+
expected_header = urllib3.response.HTTPHeaderDict(
632+
{
633+
"Accept": "application/json",
634+
"Content-Type": "application/x-www-form-urlencoded",
635+
"User-Agent": USER_AGENT,
636+
}
637+
)
638+
mock_request.assert_called_once_with(
639+
method="POST",
640+
url="https://issuer.fga.example/oauth/token",
641+
headers=expected_header,
642+
query_params=None,
643+
body=None,
644+
_preload_content=True,
645+
_request_timeout=None,
646+
post_params={
647+
"client_id": "myclientid",
648+
"client_secret": "mysecret",
649+
"grant_type": "client_credentials",
650+
},
651+
)
652+
await rest_client.close()
653+
654+
@patch.object(rest.RESTClientObject, "request")
655+
async def test_get_authentication_with_scopes_no_audience(self, mock_request):
656+
"""
657+
Test that scope is sent and audience is omitted when only scopes are provided
658+
(standard OAuth2 flow)
659+
"""
660+
response_body = """
661+
{
662+
"expires_in": 120,
663+
"access_token": "AABBCCDD"
664+
}
665+
"""
666+
mock_request.return_value = mock_response(response_body, 200)
667+
668+
credentials = Credentials(
669+
method="client_credentials",
670+
configuration=CredentialConfiguration(
671+
client_id="myclientid",
672+
client_secret="mysecret",
673+
api_issuer="issuer.fga.example",
674+
scopes="read write",
675+
),
676+
)
677+
rest_client = rest.RESTClientObject(Configuration())
678+
client = OAuth2Client(credentials)
679+
auth_header = await client.get_authentication_header(rest_client)
680+
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
681+
expected_header = urllib3.response.HTTPHeaderDict(
682+
{
683+
"Accept": "application/json",
684+
"Content-Type": "application/x-www-form-urlencoded",
685+
"User-Agent": USER_AGENT,
686+
}
687+
)
688+
mock_request.assert_called_once_with(
689+
method="POST",
690+
url="https://issuer.fga.example/oauth/token",
691+
headers=expected_header,
692+
query_params=None,
693+
body=None,
694+
_preload_content=True,
695+
_request_timeout=None,
696+
post_params={
697+
"client_id": "myclientid",
698+
"client_secret": "mysecret",
699+
"grant_type": "client_credentials",
700+
"scope": "read write",
701+
},
702+
)
703+
await rest_client.close()

test/sync/oauth2_test.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,4 +378,104 @@ def test_get_authentication_retries_5xx_responses(self, mock_request):
378378
self.assertEqual(mock_request.call_count, 4) # 3 retries, 1 success
379379
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
380380

381+
@patch.object(rest.RESTClientObject, "request")
382+
def test_get_authentication_without_audience(self, mock_request):
383+
"""
384+
Test that audience is omitted from the token request when not provided
385+
(standard OAuth2 flow without Auth0 audience extension)
386+
"""
387+
response_body = """
388+
{
389+
"expires_in": 120,
390+
"access_token": "AABBCCDD"
391+
}
392+
"""
393+
mock_request.return_value = mock_response(response_body, 200)
394+
395+
credentials = Credentials(
396+
method="client_credentials",
397+
configuration=CredentialConfiguration(
398+
client_id="myclientid",
399+
client_secret="mysecret",
400+
api_issuer="issuer.fga.example",
401+
),
402+
)
403+
rest_client = rest.RESTClientObject(Configuration())
404+
client = OAuth2Client(credentials)
405+
auth_header = client.get_authentication_header(rest_client)
406+
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
407+
expected_header = urllib3.response.HTTPHeaderDict(
408+
{
409+
"Accept": "application/json",
410+
"Content-Type": "application/x-www-form-urlencoded",
411+
"User-Agent": USER_AGENT,
412+
}
413+
)
414+
mock_request.assert_called_once_with(
415+
method="POST",
416+
url="https://issuer.fga.example/oauth/token",
417+
headers=expected_header,
418+
query_params=None,
419+
body=None,
420+
_preload_content=True,
421+
_request_timeout=None,
422+
post_params={
423+
"client_id": "myclientid",
424+
"client_secret": "mysecret",
425+
"grant_type": "client_credentials",
426+
},
427+
)
428+
rest_client.close()
429+
430+
@patch.object(rest.RESTClientObject, "request")
431+
def test_get_authentication_with_scopes_no_audience(self, mock_request):
432+
"""
433+
Test that scope is sent and audience is omitted when only scopes are provided
434+
(standard OAuth2 flow)
435+
"""
436+
response_body = """
437+
{
438+
"expires_in": 120,
439+
"access_token": "AABBCCDD"
440+
}
441+
"""
442+
mock_request.return_value = mock_response(response_body, 200)
443+
444+
credentials = Credentials(
445+
method="client_credentials",
446+
configuration=CredentialConfiguration(
447+
client_id="myclientid",
448+
client_secret="mysecret",
449+
api_issuer="issuer.fga.example",
450+
scopes="read write",
451+
),
452+
)
453+
rest_client = rest.RESTClientObject(Configuration())
454+
client = OAuth2Client(credentials)
455+
auth_header = client.get_authentication_header(rest_client)
456+
self.assertEqual(auth_header, {"Authorization": "Bearer AABBCCDD"})
457+
expected_header = urllib3.response.HTTPHeaderDict(
458+
{
459+
"Accept": "application/json",
460+
"Content-Type": "application/x-www-form-urlencoded",
461+
"User-Agent": USER_AGENT,
462+
}
463+
)
464+
mock_request.assert_called_once_with(
465+
method="POST",
466+
url="https://issuer.fga.example/oauth/token",
467+
headers=expected_header,
468+
query_params=None,
469+
body=None,
470+
_preload_content=True,
471+
_request_timeout=None,
472+
post_params={
473+
"client_id": "myclientid",
474+
"client_secret": "mysecret",
475+
"grant_type": "client_credentials",
476+
"scope": "read write",
477+
},
478+
)
479+
rest_client.close()
480+
381481
rest_client.close()

0 commit comments

Comments
 (0)