Repository: jupyterhub/jupyterhub

Issue:
HubAuth does not work with service tokens
It is currently not possible to authenticate a service using the HubAuth class.
When attempting to use a service token to authenticate a service via `/api/authorizations/token/{token}`,
 it returns a 404.

**How to reproduce the issue**
Take a service token and make a request to `/api/authorizations/token/{token}` using the token and the proper authorization header.

**What you expected to happen**
A User model is returned about the service, with a `name` and `admin` field.

**What actually happens**
The request returns with a 404

**Share what version of JupyterHub you are using**
0.7.2



Discussion:

Resolving patch:
diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py
--- a/jupyterhub/apihandlers/auth.py
+++ b/jupyterhub/apihandlers/auth.py
@@ -18,7 +18,11 @@ def get(self, token):
         orm_token = orm.APIToken.find(self.db, token)
         if orm_token is None:
             raise web.HTTPError(404)
-        self.write(json.dumps(self.user_model(self.users[orm_token.user])))
+        if orm_token.user:
+            model = self.user_model(self.users[orm_token.user])
+        elif orm_token.service:
+            model = self.service_model(orm_token.service)
+        self.write(json.dumps(model))
 
     @gen.coroutine
     def post(self):
diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py
--- a/jupyterhub/apihandlers/base.py
+++ b/jupyterhub/apihandlers/base.py
@@ -89,6 +89,7 @@ def write_error(self, status_code, **kwargs):
     def user_model(self, user):
         """Get the JSON model for a User object"""
         model = {
+            'kind': 'user',
             'name': user.name,
             'admin': user.admin,
             'groups': [ g.name for g in user.groups ],
@@ -105,8 +106,17 @@ def user_model(self, user):
     def group_model(self, group):
         """Get the JSON model for a Group object"""
         return {
+            'kind': 'group',
             'name': group.name,
-            'users': [ u.name for u in group.users ]
+            'users': [ u.name for u in group.users ],
+        }
+
+    def service_model(self, service):
+        """Get the JSON model for a Service object"""
+        return {
+            'kind': 'service',
+            'name': service.name,
+            'admin': service.admin,
         }
 
     _user_model_types = {
@@ -152,6 +162,7 @@ def _check_group_model(self, model):
             if not isinstance(groupname, str):
                 raise web.HTTPError(400, ("group names must be str, not %r", type(groupname)))
 
+
     def options(self, *args, **kwargs):
         self.set_header('Access-Control-Allow-Headers', 'accept, content-type')
         self.finish()
diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py
--- a/jupyterhub/services/auth.py
+++ b/jupyterhub/services/auth.py
@@ -12,6 +12,7 @@
 import socket
 import time
 from urllib.parse import quote
+import warnings
 
 import requests
 
@@ -183,7 +184,6 @@ def _check_hub_authorization(self, url, cache_key=None, use_cache=True):
             raise HTTPError(500, msg)
 
         data = None
-        print(r.status_code)
         if r.status_code == 404:
             app_log.warning("No Hub user identified for request")
         elif r.status_code == 403:
@@ -330,9 +330,19 @@ def get(self):
                 ...
 
     """
+    hub_services = None # set of allowed services
     hub_users = None # set of allowed users
     hub_groups = None # set of allowed groups
     
+    @property
+    def allow_all(self):
+        """Property indicating that all successfully identified user
+        or service should be allowed.
+        """
+        return (self.hub_services is None
+            and self.hub_users is None
+            and self.hub_groups is None)
+
     # self.hub_auth must be a HubAuth instance.
     # If nothing specified, use default config,
     # which will be configured with defaults
@@ -352,34 +362,45 @@ def get_login_url(self):
         """Return the Hub's login URL"""
         return self.hub_auth.login_url
 
-    def check_hub_user(self, user_model):
-        """Check whether Hub-authenticated user should be allowed.
+    def check_hub_user(self, model):
+        """Check whether Hub-authenticated user or service should be allowed.
 
         Returns the input if the user should be allowed, None otherwise.
 
         Override if you want to check anything other than the username's presence in hub_users list.
 
         Args:
-            user_model (dict): the user model returned from :class:`HubAuth`
+            model (dict): the user or service model returned from :class:`HubAuth`
         Returns:
             user_model (dict): The user model if the user should be allowed, None otherwise.
         """
-        name = user_model['name']
-        if self.hub_users is None and self.hub_groups is None:
-            # no whitelist specified, allow any authenticated Hub user
-            app_log.debug("Allowing Hub user %s (all Hub users allowed)", name)
-            return user_model
+        
+        name = model['name']
+        kind = model.get('kind', 'user')
+        if self.allow_all:
+            app_log.debug("Allowing Hub %s %s (all Hub users and services allowed)", kind, name)
+            return model
+
+        if kind == 'service':
+            # it's a service, check hub_services
+            if self.hub_services and name in self.hub_services:
+                app_log.debug("Allowing whitelisted Hub service %s", name)
+                return model
+            else:
+                app_log.warning("Not allowing Hub service %s", name)
+                return None
+
         if self.hub_users and name in self.hub_users:
             # user in whitelist
             app_log.debug("Allowing whitelisted Hub user %s", name)
-            return user_model
-        elif self.hub_groups and set(user_model['groups']).intersection(self.hub_groups):
-            allowed_groups = set(user_model['groups']).intersection(self.hub_groups)
+            return model
+        elif self.hub_groups and set(model['groups']).intersection(self.hub_groups):
+            allowed_groups = set(model['groups']).intersection(self.hub_groups)
             app_log.debug("Allowing Hub user %s in group(s) %s", name, ','.join(sorted(allowed_groups)))
             # group in whitelist
-            return user_model
+            return model
         else:
-            app_log.warning("Not allowing Hub user %s" % name)
+            app_log.warning("Not allowing Hub user %s", name)
             return None
 
     def get_current_user(self):


Test patch:
diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py
--- a/jupyterhub/tests/mocking.py
+++ b/jupyterhub/tests/mocking.py
@@ -127,6 +127,8 @@ class MockHub(JupyterHub):
     
     base_url = '/@/space%20word/'
     
+    log_datefmt = '%M:%S'
+    
     @default('subdomain_host')
     def _subdomain_host_default(self):
         return os.environ.get('JUPYTERHUB_TEST_SUBDOMAIN_HOST', '')
diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py
--- a/jupyterhub/tests/test_api.py
+++ b/jupyterhub/tests/test_api.py
@@ -168,6 +168,7 @@ def test_get_users(app):
         u.pop('last_activity')
     assert users == [
         {
+            'kind': 'user',
             'name': 'admin',
             'groups': [],
             'admin': True,
@@ -175,6 +176,7 @@ def test_get_users(app):
             'pending': None,
         },
         {
+            'kind': 'user',
             'name': 'user',
             'groups': [],
             'admin': False,
@@ -209,6 +211,7 @@ def test_get_user(app):
     user = r.json()
     user.pop('last_activity')
     assert user == {
+        'kind': 'user',
         'name': name,
         'groups': [],
         'admin': False,
@@ -570,6 +573,7 @@ def test_groups_list(app):
     r.raise_for_status()
     reply = r.json()
     assert reply == [{
+        'kind': 'group',
         'name': 'alphaflight',
         'users': []
     }]
@@ -589,6 +593,7 @@ def test_group_get(app):
     r.raise_for_status()
     reply = r.json()
     assert reply == {
+        'kind': 'group',
         'name': 'alphaflight',
         'users': ['sasquatch']
     }
diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py
--- a/jupyterhub/tests/test_services_auth.py
+++ b/jupyterhub/tests/test_services_auth.py
@@ -1,4 +1,6 @@
+from binascii import hexlify
 import json
+import os
 from queue import Queue
 import sys
 from threading import Thread
@@ -217,7 +219,8 @@ def finish_thread():
         assert auth.login_url in r.headers['Location']
 
 
-def test_service_cookie_auth(app, mockservice_url):
+def test_hubauth_cookie(app, mockservice_url):
+    """Test HubAuthenticated service with user cookies"""
     cookies = app.login_user('badger')
     r = requests.get(public_url(app, mockservice_url) + '/whoami/', cookies=cookies)
     r.raise_for_status()
@@ -230,7 +233,8 @@ def test_service_cookie_auth(app, mockservice_url):
     }
 
 
-def test_service_token_auth(app, mockservice_url):
+def test_hubauth_token(app, mockservice_url):
+    """Test HubAuthenticated service with user API tokens"""
     u = add_user(app.db, name='river')
     token = u.new_api_token()
     app.db.commit()
@@ -267,3 +271,44 @@ def test_service_token_auth(app, mockservice_url):
     path = urlparse(location).path
     assert path.endswith('/hub/login')
 
+
+def test_hubauth_service_token(app, mockservice_url, io_loop):
+    """Test HubAuthenticated service with service API tokens"""
+    
+    token = hexlify(os.urandom(5)).decode('utf8')
+    name = 'test-api-service'
+    app.service_tokens[token] = name
+    io_loop.run_sync(app.init_api_tokens)
+    
+    # token in Authorization header
+    r = requests.get(public_url(app, mockservice_url) + '/whoami/',
+        headers={
+            'Authorization': 'token %s' % token,
+        })
+    r.raise_for_status()
+    reply = r.json()
+    assert reply == {
+        'kind': 'service',
+        'name': name,
+        'admin': False,
+    }
+
+    # token in ?token parameter
+    r = requests.get(public_url(app, mockservice_url) + '/whoami/?token=%s' % token)
+    r.raise_for_status()
+    reply = r.json()
+    assert reply == {
+        'kind': 'service',
+        'name': name,
+        'admin': False,
+    }
+
+    r = requests.get(public_url(app, mockservice_url) + '/whoami/?token=no-such-token',
+        allow_redirects=False,
+    )
+    assert r.status_code == 302
+    assert 'Location' in r.headers
+    location = r.headers['Location']
+    path = urlparse(location).path
+    assert path.endswith('/hub/login')
+