diff --git a/examples/custom-scopes/grades.py b/examples/custom-scopes/grades.py
index e3831536..4b805264 100644
--- a/examples/custom-scopes/grades.py
+++ b/examples/custom-scopes/grades.py
@@ -43,13 +43,31 @@ def require_scope(scopes):
return wrap
+class MyGradesHandler(HubOAuthenticated, RequestHandler):
+ # no hub_scopes, anyone with access to this service
+ # will be able to visit this URL
+
+ @authenticated
+ def get(self):
+ self.write("
My grade
")
+ name = self.current_user["name"]
+ grades = self.settings["grades"]
+ self.write(f"My name is: {escape(name)}
")
+ if name in grades:
+ self.write(f"My grade is: {escape(str(grades[name]))}
")
+ else:
+ self.write("No grade entered
")
+ if READ_SCOPE in self.current_user["scopes"]:
+ self.write('enter grades')
+
+
class GradesHandler(HubOAuthenticated, RequestHandler):
# default scope for this Handler: read-only
hub_scopes = [READ_SCOPE]
def _render(self):
grades = self.settings["grades"]
- self.write("Grades
")
+ self.write("All grades
")
self.write("")
self.write("Student | Grade |
")
for student, grade in grades.items():
@@ -92,7 +110,8 @@ def main():
app = Application(
[
- (base_url, GradesHandler),
+ (base_url, MyGradesHandler),
+ (url_path_join(base_url, 'grades/'), GradesHandler),
(
url_path_join(base_url, 'oauth_callback'),
HubOAuthCallbackHandler,
diff --git a/examples/custom-scopes/jupyterhub_config.py b/examples/custom-scopes/jupyterhub_config.py
index cc285215..735b1092 100644
--- a/examples/custom-scopes/jupyterhub_config.py
+++ b/examples/custom-scopes/jupyterhub_config.py
@@ -31,7 +31,13 @@ c.JupyterHub.load_roles = [
"name": "grader",
# grant graders access to write grades
"scopes": ["custom:grades:write"],
- "users": ["grader", "instructor"],
+ "users": ["grader"],
+ },
+ {
+ "name": "instructor",
+ # grant instructors access to read, but not write grades
+ "scopes": ["custom:grades:read"],
+ "users": ["instructor"],
},
]
diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py
index 2ec2dec0..9d05f1cb 100644
--- a/jupyterhub/apihandlers/auth.py
+++ b/jupyterhub/apihandlers/auth.py
@@ -283,6 +283,21 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
raise web.HTTPError(
403, f"You do not have permission to access {client.description}"
)
+
+ # subset role names to those held by authenticating user
+ requested_role_names = set(role_names)
+ user = self.current_user
+ user_role_names = {role.name for role in user.roles}
+ allowed_role_names = requested_role_names.intersection(user_role_names)
+ excluded_role_names = requested_role_names.difference(allowed_role_names)
+ if excluded_role_names:
+ self.log.info(
+ f"Service {client.description} requested roles {','.join(role_names)}"
+ f" for user {self.current_user.name},"
+ f" granting only {','.join(allowed_role_names) or '[]'}."
+ )
+ role_names = list(allowed_role_names)
+
if not self.needs_oauth_confirm(self.current_user, client, role_names):
self.log.debug(
"Skipping oauth confirmation for %s accessing %s",
@@ -381,6 +396,10 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
# The scopes the user actually authorized, i.e. checkboxes
# that were selected.
scopes = self.get_arguments('scopes')
+ if scopes == []:
+ # avoid triggering default scopes (provider selects default scopes when scopes is falsy)
+ # when an explicit empty list is authorized
+ scopes = ["identify"]
# credentials we need in the validator
credentials = self.add_credentials()
diff --git a/jupyterhub/tests/mockservice.py b/jupyterhub/tests/mockservice.py
index 082aa65d..92d6501e 100644
--- a/jupyterhub/tests/mockservice.py
+++ b/jupyterhub/tests/mockservice.py
@@ -23,6 +23,7 @@ from tornado import httpserver
from tornado import ioloop
from tornado import log
from tornado import web
+from tornado.httputil import url_concat
from jupyterhub.services.auth import HubAuthenticated
from jupyterhub.services.auth import HubOAuthCallbackHandler
@@ -76,6 +77,13 @@ class OWhoAmIHandler(HubOAuthenticated, web.RequestHandler):
Uses OAuth login flow
"""
+ def get_login_url(self):
+ login_url = super().get_login_url()
+ scopes = self.get_argument("request-scope", None)
+ if scopes is not None:
+ login_url = url_concat(login_url, {"scope": scopes})
+ return login_url
+
@web.authenticated
def get(self):
self.write(self.get_current_user())
diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py
index 0edfd2db..a4f3e805 100644
--- a/jupyterhub/tests/test_pages.py
+++ b/jupyterhub/tests/test_pages.py
@@ -1134,6 +1134,7 @@ async def test_oauth_page_scope_appearance(
)
service = mockservice_url
user = create_user_with_scopes("access:services")
+ roles.grant_role(app.db, user, service_role)
oauth_client = (
app.db.query(orm.OAuthClient)
.filter_by(identifier=service.oauth_client_id)
diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py
index 17474fd2..8e2054fa 100644
--- a/jupyterhub/tests/test_services_auth.py
+++ b/jupyterhub/tests/test_services_auth.py
@@ -5,9 +5,11 @@ import sys
from binascii import hexlify
from unittest import mock
from urllib.parse import parse_qs
+from urllib.parse import quote
from urllib.parse import urlparse
import pytest
+from bs4 import BeautifulSoup
from pytest import raises
from tornado.httputil import url_concat
@@ -227,6 +229,9 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
(["admin", "user"], ["user"], ["user"]),
(["user", "token", "server"], ["token", "user"], ["token", "user"]),
(["admin", "user", "read-only"], ["read-only"], ["read-only"]),
+ # requesting valid subset, some not held by user
+ (["admin", "user"], ["admin", "user"], ["user"]),
+ (["admin", "user"], ["admin"], []),
],
)
async def test_oauth_service_roles(
@@ -267,6 +272,8 @@ async def test_oauth_service_roles(
]
app.db.commit()
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
+ if request_roles:
+ url = url_concat(url, {"request-scope": " ".join(request_roles)})
# first request is only going to login and get us to the oauth form page
s = AsyncSession()
user = create_user_with_scopes("access:services")
@@ -276,17 +283,6 @@ async def test_oauth_service_roles(
s.cookies = await app.login_user(name)
r = await s.get(url)
- r.raise_for_status()
- # we should be looking at the oauth confirmation page
- assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
- # verify oauth state cookie was set at some point
- assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
-
- # submit the oauth form to complete authorization
- data = {}
- if request_roles:
- data["scopes"] = request_roles
- r = await s.post(r.url, data=data, headers={'Referer': r.url})
if expected_roles is None:
# expected failed auth, stop here
# verify expected 'invalid scope' error, not server error
@@ -296,6 +292,21 @@ async def test_oauth_service_roles(
assert r.status_code == 400
return
r.raise_for_status()
+ # we should be looking at the oauth confirmation page
+ assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
+ # verify oauth state cookie was set at some point
+ assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
+
+ page = BeautifulSoup(r.text, "html.parser")
+ scope_inputs = page.find_all("input", {"name": "scopes"})
+ scope_values = [input["value"] for input in scope_inputs]
+ print("Submitting request with scope values", scope_values)
+ # submit the oauth form to complete authorization
+ data = {}
+ if scope_values:
+ data["scopes"] = scope_values
+ r = await s.post(r.url, data=data, headers={'Referer': r.url})
+ r.raise_for_status()
assert r.url == url
# verify oauth cookie is set
assert 'service-%s' % service.name in set(s.cookies.keys())
@@ -395,7 +406,11 @@ async def test_oauth_access_scopes(
@pytest.mark.parametrize(
"token_roles, hits_page",
- [([], True), (['writer'], True), (['writer', 'reader'], False)],
+ [
+ ([], True),
+ (['writer'], True),
+ (['writer', 'reader'], False),
+ ],
)
async def test_oauth_page_hit(
app,
@@ -411,6 +426,8 @@ async def test_oauth_page_hit(
}
service = mockservice_url
user = create_user_with_scopes("access:services", "self")
+ for role in test_roles.values():
+ roles.grant_role(app.db, user, role)
user.new_api_token()
token = user.api_tokens[0]
token.roles = [test_roles[t] for t in token_roles]