Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add allowed_scopes to all authenticators to allow some users based on granted scopes #719

Merged
merged 15 commits into from
Apr 26, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions oauthenticator/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,24 @@ def _logout_redirect_url_default(self):
""",
)

required_scopes = List(
consideRatio marked this conversation as resolved.
Show resolved Hide resolved
Unicode(),
config=True,
help="""
List of scopes that must be granted to allow login.

All the scopes listed in this config must be present in the OAuth2 grant
from the authorizing server to allow the user to login. We request all
the scopes listed in the 'scope' config, but only a subset of these may
be granted by the authorization server. This may happen if the user does not
have permissions to access a requested scope, or has chosen to not give consent
for a particular scope. If the scopes listed in this config are not granted,
the user will not be allowed to log in.

See the OAuth documentation of your OAuth provider for various options.
""",
)

extra_authorize_params = Dict(
config=True,
help="""
Expand Down Expand Up @@ -1025,6 +1043,18 @@ async def check_allowed(self, username, auth_model):
if username in self.allowed_users:
return True

# If we specific scope grants are required, validate that they have been granted
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've considered if this should be in an earlier stage than check_allowed. Its nice to have it here if seen as authorization logic, but it could be seen as a technical requirement - for example to get relevant info for the username. Then, it would be more suitable that not meeting this criteria is seen ss an error rather than 403.

Hmmmm... Consider if we request email scope and username_claim email and declare it as email scope required but don't get it - should that be another kind of error rather than denied authorization 403?

If we think so, this could be more like an assertion early in update_auth_model instead raising an error maybe? Wdyt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what i think, but it wouldn't be breaking to relocate this check so I'm open with going onwards either way

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. There are two ways to think about this:

  1. It's part of authentication logic, as if we aren't granted a specific claim from a scope, we can't even log the user in (for username_claim, for example). So if the scope isn't granted, we can't actually validate who this user is. So it gets relocated elsewhere.
  2. It is part of authorization logic, where scopes are used as a way to 'gate' access after we could validate who the user is. So we know who the user is, but we don't want to give them access. In this case it stays where it is.

So how do we best determine if this is part of authentication or authorization?

I went to look at possible available scopes for a bunch of authenticators:

username_claim feels clearly part of authentication to me, and right now, if we don't get a username_claim back for any reason, it's already a 403. But the 403 is a little bit unclear on why exactly the permission was denied and can be frustrating for the user. However, the scopes that can be related to username_claim are few in number. In GitHub for example, it really is just user - its unlikely you would want the username to be derived from anything else (for the most part). I think this is generally true for most auth providers, particularly given the prevalance & design decisions of openid connect. This means that when configuring, if the username_claim is not present due to a missing scope, it most likely is because the hub admin did not ask for the scope, and it's going to be clear to them in initial testing.

However, you may want to deny authorization for some subsets - for example, someone may want to setup a specific ssh key in github for a JupyterHub, and hence reject authorization if they don't grant write:public_key, even though it has nothing to do with authentication itself - we know who the user is, just don't want them in here.

I'm a big fan of @GeorgianaElena's work in #594, and think that separation of authentication and authorization here makes things easier to reason about. I think the most likely use of this feature is authorization and not authentication. username_claim being set to something that isn't present if a specific scope is not granted is IMO a different problem, and we can tackle that differently if you desire - I'd say the primary extra bit there is to find a way to provide useful error information.

All this to say, I think it's ok for this check to remain here :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened #721 to make the missing username_claim situation slightly easier.

Copy link
Member

@consideRatio consideRatio Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reasoning about this - in great depth as well @yuvipanda!! I think this makes a good case for considering this to be part of authorization (authz) logic rather than authentication (authn) logic!

if self.required_scopes:
granted_scopes = auth_model.get('auth_state', {}).get('scope', [])
missing_scopes = set(self.required_scopes) - set(granted_scopes)
if missing_scopes:
self.log.info(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is often useful to log which scopes are held and not granted, rather than just log what wasn't granted.

I suspect that the most common case for this branch will be that scope is entirely missing due to some misconfiguration or something, and it's helpful for that to appear distinct from a completely successful authentication with insufficient permissions. Another example would be a typo in a scope name. Showing the held scopes would make that much easier to identify and resolve.

f"Denying access to user {username} - scopes {missing_scopes} were not granted"
)
return False
else:
return True

# users should be explicitly allowed via config, otherwise they aren't
return False

Expand Down
5 changes: 5 additions & 0 deletions oauthenticator/tests/mocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def setup_oauth_mock(
user_path=None,
token_type='Bearer',
token_request_style='post',
scope="",
):
"""setup the mock client for OAuth

Expand All @@ -125,6 +126,7 @@ def setup_oauth_mock(
access_token_path (str): The path for the access token request (e.g. /access_token)
user_path (str): The path for requesting (e.g. /user)
token_type (str): the token_type field for the provider
scope (str): The scope field returned by the provider
"""

if user_path is None and token_request_style != "jwt":
Expand Down Expand Up @@ -164,6 +166,8 @@ def access_token(request):
'access_token': token,
'token_type': token_type,
}
if scope:
model['scope'] = scope
if token_request_style == 'jwt':
model['id_token'] = user['id_token']
return model
Expand All @@ -175,6 +179,7 @@ def get_user(request):
token = auth_header.split(None, 1)[1]
else:
query = parse_qs(urlparse(request.url).query)

if 'access_token' in query:
token = query['access_token'][0]
else:
Expand Down
18 changes: 18 additions & 0 deletions oauthenticator/tests/test_generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def generic_client(client):
host='generic.horse',
access_token_path='/oauth/access_token',
user_path='/oauth/userinfo',
scope='basic',
)
return client

Expand Down Expand Up @@ -200,6 +201,23 @@ async def test_generic_data(get_authenticator, generic_client):
assert auth_model


@mark.parametrize(
["requested_scopes", "allowed"], [(["advanced"], False), (["basic"], True)]
)
async def test_required_scopes(
get_authenticator, generic_client, requested_scopes, allowed
):
c = Config()
c.GenericOAuthenticator.required_scopes = requested_scopes
c.GenericOAuthenticator.scope = list(requested_scopes)
authenticator = get_authenticator(config=c)

handled_user_model = user_model("user1")
handler = generic_client.handler_for_user(handled_user_model)
auth_model = await authenticator.authenticate(handler)
assert allowed == await authenticator.check_allowed(auth_model["name"], auth_model)


async def test_generic_callable_username_key(get_authenticator, generic_client):
c = Config()
c.GenericOAuthenticator.allow_all = True
Expand Down