From 35bf6055a2e2eba253eb213f8a6c5e116184ec53 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 19 Sep 2017 13:21:45 -0700 Subject: [PATCH] Appears functional. --- oauthenticator/__init__.py | 1 + oauthenticator/cilogon.py | 65 +++++++++++++++++++--------- oauthenticator/tests/test_cilogon.py | 49 +++++++++++++++++++++ 3 files changed, 94 insertions(+), 21 deletions(-) create mode 100644 oauthenticator/tests/test_cilogon.py diff --git a/oauthenticator/__init__.py b/oauthenticator/__init__.py index a253ce9e28..4d7b03e32f 100644 --- a/oauthenticator/__init__.py +++ b/oauthenticator/__init__.py @@ -4,5 +4,6 @@ from .github import * from .bitbucket import * from .google import * +from .cilogon import * from ._version import __version__, version_info diff --git a/oauthenticator/cilogon.py b/oauthenticator/cilogon.py index 11789b11af..29b7b68fc8 100644 --- a/oauthenticator/cilogon.py +++ b/oauthenticator/cilogon.py @@ -13,7 +13,7 @@ - For user whitelist/admin purposes, username will be the sub claim. This is unlikely to work as a Unix userid. Typically an actual implementation will specify the identity provider and scopes sufficient to retrieve an - ePPN or other unique identifiers. + ePPN or other unique identifier more amenable to being used as a username. """ @@ -28,6 +28,8 @@ from tornado.httputil import url_concat from tornado.httpclient import HTTPRequest, AsyncHTTPClient +from traitlets import Unicode + from jupyterhub.auth import LocalAuthenticator from .oauth2 import OAuthLoginHandler, OAuthenticator @@ -35,13 +37,16 @@ CILOGON_HOST = os.environ.get('CILOGON_HOST') or 'cilogon.org' -def _api_headers(access_token): +def _api_headers(): return {"Accept": "application/json", "User-Agent": "JupyterHub", - "Authorization": "token {}".format(access_token) } +def _add_access_token(access_token, params): + params["access_token"] = access_token + + class CILogonMixin(OAuth2Mixin): _OAUTH_AUTHORIZE_URL = "https://%s/authorize" % CILOGON_HOST _OAUTH_TOKEN_URL = "https://%s/oauth2/token" % CILOGON_HOST @@ -51,9 +56,9 @@ class CILogonLoginHandler(OAuthLoginHandler, CILogonMixin): """See http://www.cilogon.org/oidc for general information. The `scope` attribute is inherited from OAuthLoginHandler and is a - list of scopes requested when we acquire a CILogon token: + list of scopes requested when we acquire a CILogon token. - See cilogon_scope.md for details. + See cilogon_scope.md for details. At least 'openid' is required. The `idp` attribute is the SAML Entity ID of the user's selected identity provider. @@ -66,12 +71,30 @@ class CILogonLoginHandler(OAuthLoginHandler, CILogonMixin): skin. """ + scope = ['openid'] idp = None skin = None + def get(self): + redirect_uri = self.authenticator.get_callback_url(self) + self.log.info('OAuth redirect: %r', redirect_uri) + state = self.get_state() + self.set_state_cookie(state) + extra_params = {'state': state} + if self.idp: + extra_params["selected_idp"] = self.idp + if self.skin: + extra_params["skin"] = self.skin + + self.authorize_redirect( + redirect_uri=redirect_uri, + client_id=self.authenticator.client_id, + scope=self.scope, + extra_params=extra_params, + response_type='code') -class CILogonOAuthenticator(OAuthenticator): +class CILogonOAuthenticator(OAuthenticator): login_service = "CILogon" client_id_env = 'CILOGON_CLIENT_ID' @@ -80,7 +103,7 @@ class CILogonOAuthenticator(OAuthenticator): @gen.coroutine def authenticate(self, handler, data=None): - """We set up auth_state based on additional GitHub info if we + """We set up auth_state based on additional CILogon info if we receive it. """ code = handler.get_argument("code") @@ -88,10 +111,8 @@ def authenticate(self, handler, data=None): http_client = AsyncHTTPClient() # Exchange the OAuth code for a CILogon Access Token - # - # See: https://developer.github.com/v3/oauth/ - - # CILogon just wants a GET + # See: http://www.cilogon.org/oidc + headers = _api_headers() params = dict( client_id=self.client_id, client_secret=self.client_secret, @@ -99,29 +120,31 @@ def authenticate(self, handler, data=None): code=code, grant_type='authorization_code', ) - if self.idp: - params["selected_idp"] = self.idp - if self.skin: - params["skin"] = self.skin url = url_concat("https://%s/oauth2/token" % CILOGON_HOST, params) req = HTTPRequest(url, - headers={"Accept": "application/json"}, + headers=headers, + method="POST", + body='' ) resp = yield http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) - access_token = resp_json['access_token'] + self.log.info("Access token acquired.") # Determine who the logged in user is - req = HTTPRequest("https://%s/oauth2/userinfo" % CILOGON_HOST, - method="GET", - headers=_api_headers(access_token) + params = dict(access_token=access_token) + req = HTTPRequest(url_concat("https://%s/oauth2/userinfo" % + CILOGON_HOST, params), + headers=headers ) + self.log.info("REQ: %s / %r" % (str(req), req)) resp = yield http_client.fetch(req) resp_json = json.loads(resp.body.decode('utf8', 'replace')) + self.log.info(json.dumps(resp_json, sort_keys=True, indent=4)) + if "sub" not in resp_json or not resp_json["sub"]: return None username = resp_json["sub"] @@ -133,7 +156,7 @@ def authenticate(self, handler, data=None): # These can be used for user provisioning # in the Lab/Notebook environment. auth_state['access_token'] = access_token - # store the whole user model in auth_state.github_user + # store the whole user model in auth_state.cilogon_user auth_state['cilogon_user'] = resp_json return userdict diff --git a/oauthenticator/tests/test_cilogon.py b/oauthenticator/tests/test_cilogon.py new file mode 100644 index 0000000000..79825ef7fa --- /dev/null +++ b/oauthenticator/tests/test_cilogon.py @@ -0,0 +1,49 @@ +import re +import functools +import json +from io import BytesIO + +from pytest import fixture, mark +from urllib.parse import urlparse, parse_qs +from tornado.httpclient import HTTPRequest, HTTPResponse +from tornado.httputil import HTTPHeaders + +from ..cilogon import CILogonOAuthenticator + +from .mocks import setup_oauth_mock + + +def user_model(username): + """Return a user model""" + return { + 'sub': 'http://cilogon.org/dinosaurs/users/%s/1729' % username, + } + + +@fixture +def cilogon_client(client): + setup_oauth_mock(client, + host='cilogon.org', + access_token_path='/oauth2/token', + user_path='/oauth2/userinfo', + token_type='token', + ) + return client + + +@mark.gen_test +def test_cilogon(cilogon_client): + authenticator = CILogonOAuthenticator() + handler = cilogon_client.handler_for_user(user_model('wash')) + user_info = yield authenticator.authenticate(handler) + print(json.dumps(user_info, sort_keys=True, indent=4)) + name = user_info['name'] + assert name == 'http://cilogon.org/dinosaurs/users/wash/1729' + auth_state = user_info['auth_state'] + assert 'access_token' in auth_state + assert auth_state == { + 'access_token': auth_state['access_token'], + 'cilogon_user': { + 'sub': name + } + }