Skip to content

Commit 6a4ce26

Browse files
committed
Improve scope matching logic to return the stored token with the minimum number of scopes rather than first match
1 parent 34db3e8 commit 6a4ce26

File tree

2 files changed

+51
-6
lines changed

2 files changed

+51
-6
lines changed

src/auth0_server_python/auth_server/server_client.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,7 @@ def _merge_scope_with_defaults(
663663
default_scopes_list = default_scopes.split()
664664
request_scopes_list = (request_scope or "").split()
665665

666-
merged_scopes = default_scopes_list + [x for x in request_scopes_list if x not in default_scopes_list]
666+
merged_scopes = list(dict.fromkeys(default_scopes_list + request_scopes_list))
667667
return " ".join(merged_scopes) if merged_scopes else None
668668

669669

@@ -674,16 +674,20 @@ def _find_matching_token_set(
674674
scope: Optional[str]
675675
) -> Optional[dict[str, Any]]:
676676
audience = audience or self.DEFAULT_AUDIENCE_STATE_KEY
677-
match = None
677+
requested_scopes = set(scope.split()) if scope else set()
678+
matches: list[tuple[int, dict]] = []
678679
for token_set in token_sets:
679680
token_set_audience = token_set.get("audience")
680681
token_set_scopes = set(token_set.get("scope", "").split())
681-
requested_scopes = set(scope.split()) if scope else set()
682+
if token_set_audience == audience and token_set_scopes == requested_scopes:
683+
# short-circuit if exact match
684+
return token_set
682685
if token_set_audience == audience and token_set_scopes.issuperset(requested_scopes):
683-
match = token_set
684-
break
686+
# consider stored tokens with more scopes than requested by number of scopes
687+
matches.append((len(token_set_scopes), token_set))
685688

686-
return match
689+
# Return the token set with the smallest superset of scopes that matches the requested audience and scopes
690+
return min(matches, key=lambda t: t[0])[1] if matches else None
687691

688692
async def get_access_token_for_connection(
689693
self,

src/auth0_server_python/tests/test_server_client.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,47 @@ async def test_get_access_token_from_store_with_a_superset_of_requested_scopes(m
696696
assert token == "other_token_from_store"
697697
get_refresh_token_mock.assert_not_awaited()
698698

699+
700+
@pytest.mark.asyncio
701+
async def test_get_access_token_from_store_returns_minimum_matching_scopes(mocker):
702+
mock_state_store = AsyncMock()
703+
mock_state_store.get.return_value = {
704+
"refresh_token": None,
705+
"token_sets": [
706+
{
707+
"audience": "some_audience",
708+
"access_token": "maximum_scope_token",
709+
"scope": "read:foo write:foo read:bar write:bar admin:all",
710+
"expires_at": int(time.time()) + 500
711+
},
712+
{
713+
"audience": "some_audience",
714+
"access_token": "minimum_scope_token",
715+
"scope": "read:foo write:foo read:bar write:bar",
716+
"expires_at": int(time.time()) + 500
717+
}
718+
]
719+
}
720+
721+
client = ServerClient(
722+
domain="auth0.local",
723+
client_id="client_id",
724+
client_secret="client_secret",
725+
transaction_store=AsyncMock(),
726+
state_store=mock_state_store,
727+
secret="some-secret"
728+
)
729+
730+
get_refresh_token_mock = mocker.patch.object(client, "get_token_by_refresh_token")
731+
732+
token = await client.get_access_token(
733+
audience="some_audience",
734+
scope="read:foo read:bar"
735+
)
736+
737+
assert token == "minimum_scope_token"
738+
get_refresh_token_mock.assert_not_awaited()
739+
699740
@pytest.mark.asyncio
700741
async def test_get_access_token_for_connection_cached():
701742
mock_state_store = AsyncMock()

0 commit comments

Comments
 (0)