Skip to content
This repository was archived by the owner on Apr 19, 2026. It is now read-only.

Commit 7d10590

Browse files
committed
Merge pull request #147 from inklesspen/scopes
Revamp oauth scope handling; support requiring multiple scopes.
2 parents 9c69bc6 + 918cd19 commit 7d10590

2 files changed

Lines changed: 98 additions & 33 deletions

File tree

endpoints/test/users_id_token_test.py

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import unittest
2323

2424
import mock
25+
import pytest
2526

2627
import endpoints.api_config as api_config
2728

@@ -369,8 +370,9 @@ def testEmptyAudience(self):
369370
parsed_token, users_id_token._ISSUERS, [], self._SAMPLE_ALLOWED_CLIENT_IDS)
370371
self.assertEqual(False, result)
371372

373+
@mock.patch.object(oauth, 'get_authorized_scopes')
372374
@mock.patch.object(oauth, 'get_client_id')
373-
def AttemptOauth(self, client_id, mock_get_client_id, allowed_client_ids=None):
375+
def AttemptOauth(self, client_id, mock_get_client_id, mock_get_authorized_scopes, allowed_client_ids=None):
374376
if allowed_client_ids is None:
375377
allowed_client_ids = self._SAMPLE_ALLOWED_CLIENT_IDS
376378
# We have four cases:
@@ -381,20 +383,20 @@ def AttemptOauth(self, client_id, mock_get_client_id, allowed_client_ids=None):
381383
# mock call for every scope.
382384
if client_id is None:
383385
mock_get_client_id.side_effect = oauth.Error
386+
mock_get_authorized_scopes.side_effect = oauth.Error
384387
else:
385388
mock_get_client_id.return_value = client_id
389+
mock_get_authorized_scopes.return_value = self._SAMPLE_OAUTH_SCOPES
386390
users_id_token._set_bearer_user_vars(allowed_client_ids,
387391
self._SAMPLE_OAUTH_SCOPES)
388392
if client_id is None:
389-
for scope in self._SAMPLE_OAUTH_SCOPES:
390-
mock_get_client_id.assert_called_with(scope)
393+
mock_get_authorized_scopes.assert_called_with(self._SAMPLE_OAUTH_SCOPES)
391394
elif (list(allowed_client_ids) == users_id_token.SKIP_CLIENT_ID_CHECK or
392395
client_id in allowed_client_ids):
393396
scope = self._SAMPLE_OAUTH_SCOPES[0]
394-
mock_get_client_id.assert_called_with(scope)
397+
mock_get_client_id.assert_called_with([scope])
395398
else:
396-
for scope in self._SAMPLE_OAUTH_SCOPES:
397-
mock_get_client_id.assert_called_with(scope)
399+
mock_get_client_id.assert_called_with(self._SAMPLE_OAUTH_SCOPES)
398400

399401

400402
def assertOauthSucceeded(self, client_id):
@@ -487,10 +489,10 @@ def testGetCurrentUserEmailAndAuth(self):
487489
def testGetCurrentUserOauth(self, mock_get_current_user):
488490
mock_get_current_user.return_value = users.User('test@gmail.com')
489491

490-
os.environ['ENDPOINTS_USE_OAUTH_SCOPE'] = 'scope'
492+
os.environ['ENDPOINTS_USE_OAUTH_SCOPE'] = 'scope1 scope2'
491493
user = users_id_token.get_current_user()
492494
self.assertEqual(user.email(), 'test@gmail.com')
493-
mock_get_current_user.assert_called_once_with('scope')
495+
mock_get_current_user.assert_called_once_with(['scope1', 'scope2'])
494496

495497
def testGetTokenQueryParamOauthHeader(self):
496498
os.environ['HTTP_AUTHORIZATION'] = 'OAuth ' + self._SAMPLE_TOKEN
@@ -631,9 +633,10 @@ def testMethodCallParsesIdToken(self):
631633
self.VerifyIdToken(self.TestApiAnnotatedAtApi(),
632634
message_types.VoidMessage())
633635

636+
@mock.patch.object(oauth, 'get_authorized_scopes')
634637
@mock.patch.object(oauth, 'get_client_id')
635638
@mock.patch.object(users_id_token, '_is_local_dev')
636-
def testMaybeSetVarsWithActualRequestAccessToken(self, mock_local, mock_get_client_id):
639+
def testMaybeSetVarsWithActualRequestAccessToken(self, mock_local, mock_get_client_id, mock_get_authorized_scopes):
637640
dummy_scope = 'scope'
638641
dummy_token = 'dummy_token'
639642
dummy_email = 'test@gmail.com'
@@ -656,13 +659,15 @@ def method(self, request):
656659

657660
mock_local.return_value = False
658661
mock_get_client_id.return_value = dummy_client_id
662+
mock_get_authorized_scopes.return_value = [dummy_scope]
659663

660664
api_instance = TestApiScopes()
661665
os.environ['HTTP_AUTHORIZATION'] = 'Bearer ' + dummy_token
662666
api_instance.method(message_types.VoidMessage())
663-
self.assertEqual(os.getenv('ENDPOINTS_USE_OAUTH_SCOPE'), dummy_scope)
667+
assert os.getenv('ENDPOINTS_USE_OAUTH_SCOPE') == dummy_scope
664668
mock_local.assert_has_calls([mock.call(), mock.call()])
665-
mock_get_client_id.assert_called_once_with(dummy_scope)
669+
mock_get_client_id.assert_called_once_with([dummy_scope])
670+
mock_get_authorized_scopes.assert_called_once_with([dummy_scope])
666671

667672
@mock.patch.object(users_id_token, '_get_id_token_user')
668673
@mock.patch.object(time, 'time')
@@ -891,5 +896,28 @@ def testBadBase64(self):
891896
self._SAMPLE_CERT_URI, self.cache)
892897
self.assertIsNone(parsed_token)
893898

899+
900+
@pytest.mark.parametrize(('scopelist', 'all_scopes', 'sufficient_scopes'), [
901+
(('scope1', 'scope2'), {'scope1', 'scope2'}, {frozenset(['scope1']), frozenset(['scope2'])}),
902+
(('scope1', 'scope2 scope3'), {'scope1', 'scope2', 'scope3'}, {frozenset(['scope1']), frozenset(['scope2', 'scope3'])}),
903+
(('scope1 scope2', 'scope1 scope3'), {'scope1', 'scope2', 'scope3'}, {frozenset(['scope1', 'scope2']), frozenset(['scope1', 'scope3'])}),
904+
])
905+
def test_process_scopes(scopelist, all_scopes, sufficient_scopes):
906+
result = users_id_token._process_scopes(scopelist)
907+
assert result == (all_scopes, sufficient_scopes)
908+
909+
@pytest.mark.parametrize(('authorized_scopes', 'sufficient_scopes', 'is_valid'), [
910+
(['scope1'], {frozenset(['scope1'])}, True),
911+
(['scope1'], {frozenset(['scope1', 'scope2'])}, False),
912+
(['scope1', 'scope2'], {frozenset(['scope1'])}, True),
913+
(['scope1', 'scope2'], {frozenset(['scope1']), frozenset(['scope2'])}, True),
914+
(['scope1', 'scope2'], {frozenset(['scope1', 'scope2'])}, True),
915+
(['scope1'], {frozenset(['scope1']), frozenset(['scope2', 'scope3'])}, True),
916+
(['scope2'], {frozenset(['scope1']), frozenset(['scope2', 'scope3'])}, False),
917+
(['scope2', 'scope3'], {frozenset(['scope1']), frozenset(['scope2', 'scope3'])}, True),
918+
])
919+
def test_are_scopes_sufficient(authorized_scopes, sufficient_scopes, is_valid):
920+
assert users_id_token._are_scopes_sufficient(authorized_scopes, sufficient_scopes) is is_valid
921+
894922
if __name__ == '__main__':
895923
unittest.main()

endpoints/users_id_token.py

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def get_current_user():
125125
# We can get more information from the oauth.get_current_user function,
126126
# as long as we know what scope to use. Since that scope has been
127127
# cached, we can just return this:
128-
return oauth.get_current_user(os.environ[_ENV_USE_OAUTH_SCOPE])
128+
return oauth.get_current_user(os.environ[_ENV_USE_OAUTH_SCOPE].split())
129129

130130
if (_ENV_AUTH_EMAIL in os.environ and
131131
_ENV_AUTH_DOMAIN in os.environ):
@@ -316,6 +316,43 @@ def _set_oauth_user_vars(token_info, audiences, allowed_client_ids, scopes,
316316
# pylint: enable=unused-argument
317317

318318

319+
def _process_scopes(scopes):
320+
"""Parse a scopes list into a set of all scopes and a set of sufficient scope sets.
321+
322+
scopes: A list of strings, each of which is a space-separated list of scopes.
323+
Examples: ['scope1']
324+
['scope1', 'scope2']
325+
['scope1', 'scope2 scope3']
326+
327+
Returns:
328+
all_scopes: a set of strings, each of which is one scope to check for
329+
sufficient_scopes: a set of sets of strings; each inner set is
330+
a set of scopes which are sufficient for access.
331+
Example: {{'scope1'}, {'scope2', 'scope3'}}
332+
"""
333+
all_scopes = set()
334+
sufficient_scopes = set()
335+
for scope_set in scopes:
336+
scope_set_scopes = frozenset(scope_set.split())
337+
all_scopes.update(scope_set_scopes)
338+
sufficient_scopes.add(scope_set_scopes)
339+
return all_scopes, sufficient_scopes
340+
341+
342+
def _are_scopes_sufficient(authorized_scopes, sufficient_scopes):
343+
"""Check if a list of authorized scopes satisfies any set of sufficient scopes.
344+
345+
Args:
346+
authorized_scopes: a list of strings, return value from oauth.get_authorized_scopes
347+
sufficient_scopes: a set of sets of strings, return value from _process_scopes
348+
"""
349+
for sufficient_scope_set in sufficient_scopes:
350+
if sufficient_scope_set.issubset(authorized_scopes):
351+
return True
352+
return False
353+
354+
355+
319356
def _set_bearer_user_vars(allowed_client_ids, scopes):
320357
"""Validate the oauth bearer token and set endpoints auth user variables.
321358
@@ -327,27 +364,27 @@ def _set_bearer_user_vars(allowed_client_ids, scopes):
327364
allowed_client_ids: List of client IDs that are acceptable.
328365
scopes: List of acceptable scopes.
329366
"""
330-
for scope in scopes:
331-
try:
332-
client_id = oauth.get_client_id(scope)
333-
except oauth.Error:
334-
# This scope failed. Try the next.
335-
continue
336-
337-
# The client ID must be in allowed_client_ids. If allowed_client_ids is
338-
# empty, don't allow any client ID. If allowed_client_ids is set to
339-
# SKIP_CLIENT_ID_CHECK, all client IDs will be allowed.
340-
if (list(allowed_client_ids) != SKIP_CLIENT_ID_CHECK and
341-
client_id not in allowed_client_ids):
342-
_logger.warning('Client ID is not allowed: %s', client_id)
343-
return
367+
all_scopes, sufficient_scopes = _process_scopes(scopes)
368+
try:
369+
authorized_scopes = oauth.get_authorized_scopes(sorted(all_scopes))
370+
except oauth.Error:
371+
_logger.debug('Unable to get authorized scopes.', exc_info=True)
372+
return
373+
if not _are_scopes_sufficient(authorized_scopes, sufficient_scopes):
374+
_logger.debug('Authorized scopes did not satisfy scope requirements.')
375+
return
376+
client_id = oauth.get_client_id(authorized_scopes)
344377

345-
os.environ[_ENV_USE_OAUTH_SCOPE] = scope
346-
_logger.debug('Returning user from matched oauth_user.')
378+
# The client ID must be in allowed_client_ids. If allowed_client_ids is
379+
# empty, don't allow any client ID. If allowed_client_ids is set to
380+
# SKIP_CLIENT_ID_CHECK, all client IDs will be allowed.
381+
if (list(allowed_client_ids) != SKIP_CLIENT_ID_CHECK and
382+
client_id not in allowed_client_ids):
383+
_logger.warning('Client ID is not allowed: %s', client_id)
347384
return
348385

349-
_logger.debug('Oauth framework user didn\'t match oauth token user.')
350-
return None
386+
os.environ[_ENV_USE_OAUTH_SCOPE] = ' '.join(authorized_scopes)
387+
_logger.debug('get_current_user() will return user from matched oauth_user.')
351388

352389

353390
def _set_bearer_user_vars_local(token, allowed_client_ids, scopes):
@@ -392,15 +429,15 @@ def _set_bearer_user_vars_local(token, allowed_client_ids, scopes):
392429
return
393430

394431
# Verify at least one of the scopes matches.
395-
token_scopes = token_info.get('scope', '').split(' ')
396-
if not any(scope in scopes for scope in token_scopes):
432+
_, sufficient_scopes = _process_scopes(scopes)
433+
authorized_scopes = token_info.get('scope', '').split(' ')
434+
if not _are_scopes_sufficient(authorized_scopes, sufficient_scopes):
397435
_logger.warning('Oauth token scopes don\'t match any acceptable scopes.')
398436
return
399437

400438
os.environ[_ENV_AUTH_EMAIL] = token_info['email']
401439
os.environ[_ENV_AUTH_DOMAIN] = ''
402440
_logger.debug('Local dev returning user from token.')
403-
return
404441

405442

406443
def _is_local_dev():

0 commit comments

Comments
 (0)