Skip to content

Commit

Permalink
Appears functional.
Browse files Browse the repository at this point in the history
  • Loading branch information
athornton committed Sep 21, 2017
1 parent ed8b32a commit 35bf605
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 21 deletions.
1 change: 1 addition & 0 deletions oauthenticator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
from .github import *
from .bitbucket import *
from .google import *
from .cilogon import *

from ._version import __version__, version_info
65 changes: 44 additions & 21 deletions oauthenticator/cilogon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""


Expand All @@ -28,20 +28,25 @@
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

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
Expand All @@ -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.
Expand All @@ -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'
Expand All @@ -80,48 +103,48 @@ 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")
# TODO: Configure the curl_httpclient for tornado
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,
redirect_uri=self.oauth_callback_url,
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"]
Expand All @@ -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

Expand Down
49 changes: 49 additions & 0 deletions oauthenticator/tests/test_cilogon.py
Original file line number Diff line number Diff line change
@@ -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
}
}

0 comments on commit 35bf605

Please sign in to comment.