Skip to content

Commit ed6b97e

Browse files
authored
Merge pull request #719 from yuvipanda/required_scopes
Add `allowed_scopes` to all authenticators to allow some users based on granted scopes
2 parents 1f0cbc0 + 00360fa commit ed6b97e

File tree

3 files changed

+81
-1
lines changed

3 files changed

+81
-1
lines changed

oauthenticator/oauth2.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,36 @@ def _logout_redirect_url_default(self):
483483
""",
484484
)
485485

486+
allowed_scopes = List(
487+
Unicode(),
488+
config=True,
489+
help="""
490+
Allow users who have been granted *all* these scopes to log in.
491+
492+
We request all the scopes listed in the 'scope' config, but only a
493+
subset of these may be granted by the authorization server. This may
494+
happen if the user does not have permissions to access a requested
495+
scope, or has chosen to not give consent for a particular scope. If the
496+
scopes listed in this config are not granted, the user will not be
497+
allowed to log in.
498+
499+
The granted scopes will be part of the access token (fetched from self.token_url).
500+
See https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 for more
501+
information.
502+
503+
See the OAuth documentation of your OAuth provider for various options.
504+
""",
505+
)
506+
507+
@validate('allowed_scopes')
508+
def _allowed_scopes_validation(self, proposal):
509+
# allowed scopes must be a subset of requested scopes
510+
if set(proposal.value) - set(self.scope):
511+
raise ValueError(
512+
f"Allowed scopes must be a subset of requested scopes. {self.scope} is requested but {proposal.value} is allowed"
513+
)
514+
return proposal.value
515+
486516
extra_authorize_params = Dict(
487517
config=True,
488518
help="""
@@ -1060,6 +1090,8 @@ async def check_allowed(self, username, auth_model):
10601090
"""
10611091
Returns True for users allowed to be authorized
10621092
1093+
If a user must be *disallowed*, raises a 403 exception.
1094+
10631095
Overrides Authenticator.check_allowed that is called from
10641096
`Authenticator.get_authenticated_user` after
10651097
`OAuthenticator.authenticate` has been called, and therefore also after
@@ -1074,6 +1106,15 @@ async def check_allowed(self, username, auth_model):
10741106
if auth_model is None:
10751107
return True
10761108

1109+
# Allow users who have been granted specific scopes that grant them entry
1110+
if self.allowed_scopes:
1111+
granted_scopes = auth_model.get('auth_state', {}).get('scope', [])
1112+
missing_scopes = set(self.allowed_scopes) - set(granted_scopes)
1113+
if not missing_scopes:
1114+
message = f"Granting access to user {username}, as they had {self.allowed_scopes}"
1115+
self.log.info(message)
1116+
return True
1117+
10771118
if self.allow_all:
10781119
return True
10791120

oauthenticator/tests/mocks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ def setup_oauth_mock(
104104
access_token_path,
105105
user_path=None,
106106
token_type='Bearer',
107+
token_request_style='post',
108+
scope="",
107109
):
108110
"""setup the mock client for OAuth
109111
@@ -125,6 +127,7 @@ def setup_oauth_mock(
125127
access_token_path (str): The path for the access token request (e.g. /access_token)
126128
user_path (str): The path for requesting (e.g. /user)
127129
token_type (str): the token_type field for the provider
130+
scope (str): The scope field returned by the provider
128131
"""
129132

130133
client.oauth_codes = oauth_codes = {}
@@ -161,6 +164,8 @@ def access_token(request):
161164
'access_token': token,
162165
'token_type': token_type,
163166
}
167+
if scope:
168+
model['scope'] = scope
164169
if 'id_token' in user:
165170
model['id_token'] = user['id_token']
166171
return model
@@ -172,6 +177,7 @@ def get_user(request):
172177
token = auth_header.split(None, 1)[1]
173178
else:
174179
query = parse_qs(urlparse(request.url).query)
180+
175181
if 'access_token' in query:
176182
token = query['access_token'][0]
177183
else:

oauthenticator/tests/test_generic.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import json
2+
import re
23
from functools import partial
34

45
import jwt
5-
from pytest import fixture, mark
6+
from pytest import fixture, mark, raises
67
from traitlets.config import Config
78

89
from ..generic import GenericOAuthenticator
@@ -35,6 +36,7 @@ def generic_client(client):
3536
host='generic.horse',
3637
access_token_path='/oauth/access_token',
3738
user_path='/oauth/userinfo',
39+
scope='basic',
3840
)
3941
return client
4042

@@ -293,6 +295,37 @@ async def test_generic_data(get_authenticator, generic_client):
293295
assert auth_model
294296

295297

298+
@mark.parametrize(
299+
["allowed_scopes", "allowed"], [(["advanced"], False), (["basic"], True)]
300+
)
301+
async def test_allowed_scopes(
302+
get_authenticator, generic_client, allowed_scopes, allowed
303+
):
304+
c = Config()
305+
c.GenericOAuthenticator.allowed_scopes = allowed_scopes
306+
c.GenericOAuthenticator.scope = list(allowed_scopes)
307+
authenticator = get_authenticator(config=c)
308+
309+
handled_user_model = user_model("user1")
310+
handler = generic_client.handler_for_user(handled_user_model)
311+
auth_model = await authenticator.authenticate(handler)
312+
assert allowed == await authenticator.check_allowed(auth_model["name"], auth_model)
313+
314+
315+
async def test_allowed_scopes_validation_scope_subset(get_authenticator):
316+
c = Config()
317+
# Test that if we require more scopes than we request, validation fails
318+
c.GenericOAuthenticator.allowed_scopes = ["a", "b"]
319+
c.GenericOAuthenticator.scope = ["a"]
320+
with raises(
321+
ValueError,
322+
match=re.escape(
323+
"Allowed scopes must be a subset of requested scopes. ['a'] is requested but ['a', 'b'] is allowed"
324+
),
325+
):
326+
get_authenticator(config=c)
327+
328+
296329
async def test_generic_callable_username_key(get_authenticator, generic_client):
297330
c = Config()
298331
c.GenericOAuthenticator.allow_all = True

0 commit comments

Comments
 (0)