Repository: jupyterhub/jupyterhub

Issue:
Update user's auth state from a running notebook server
I have a hub where we forward the oauth2 token from the login to the user's notebook server ([like so](https://github.com/jupyterhub/oauthenticator/blob/master/examples/auth_state/jupyterhub_config.py)). There they can use it to make auth'ed requests to a few services.

My question is what do others do to keep the token that the notebook server has fresh? The oauth2 token is only valid for ~24h which is much shorter than the "logout time" for the hub (14days by default?).

Right now I have a nb server extension that periodically refreshes the token but as soon as you do that the auth state that the hub has is outdated (the oauth refresh token there becomes invalid). So I am thinking of writing a hub service that the nbserver extension can talk to. Once it refreshes the tokens it then uses the service to update what the hub's user object contains.

But this sounds quite convoluted so wondering what others do in this situation.


Discussion:
+1 I'm facing the exact same use case in our corporate auth system.

Resolving patch:
diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py
--- a/jupyterhub/apihandlers/auth.py
+++ b/jupyterhub/apihandlers/auth.py
@@ -92,7 +92,7 @@ def get(self, cookie_name, cookie_value=None):
 
 class OAuthHandler(BaseHandler, OAuth2Handler):
     """Implement OAuth provider handlers
-    
+
     OAuth2Handler sets `self.provider` in initialize,
     but we are already passing the Provider object via settings.
     """
diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py
--- a/jupyterhub/apihandlers/base.py
+++ b/jupyterhub/apihandlers/base.py
@@ -71,7 +71,7 @@ def get_json_body(self):
             self.log.error("Couldn't parse JSON", exc_info=True)
             raise web.HTTPError(400, 'Invalid JSON in body of request')
         return model
-    
+
     def write_error(self, status_code, **kwargs):
         """Write JSON errors instead of HTML"""
         exc_info = kwargs.get('exc_info')
@@ -142,6 +142,7 @@ def service_model(self, service):
         'name': str,
         'admin': bool,
         'groups': list,
+        'auth_state': dict,
     }
 
     _group_model_types = {
@@ -151,7 +152,7 @@ def service_model(self, service):
 
     def _check_model(self, model, model_types, name):
         """Check a model provided by a REST API request
-        
+
         Args:
             model (dict): user-provided model
             model_types (dict): dict of key:type used to validate types and keys
diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py
--- a/jupyterhub/apihandlers/users.py
+++ b/jupyterhub/apihandlers/users.py
@@ -17,7 +17,7 @@ class SelfAPIHandler(APIHandler):
 
     Based on the authentication info. Acts as a 'whoami' for auth tokens.
     """
-    def get(self):
+    async def get(self):
         user = self.get_current_user()
         if user is None:
             # whoami can be accessed via oauth token
@@ -44,7 +44,7 @@ async def post(self):
         # admin is set for all users
         # to create admin and non-admin users requires at least two API requests
         admin = data.get('admin', False)
-        
+
         to_create = []
         invalid_names = []
         for name in usernames:
@@ -95,7 +95,7 @@ def m(self, name, *args, **kwargs):
             raise web.HTTPError(403)
         if not (current.name == name or current.admin):
             raise web.HTTPError(403)
-        
+
         # raise 404 if not found
         if not self.find_user(name):
             raise web.HTTPError(404)
@@ -105,9 +105,17 @@ def m(self, name, *args, **kwargs):
 class UserAPIHandler(APIHandler):
 
     @admin_or_self
-    def get(self, name):
+    async def get(self, name):
         user = self.find_user(name)
-        self.write(json.dumps(self.user_model(user)))
+        user_ = self.user_model(user)
+        # auth state will only be shown if the requestor is an admin
+        # this means users can't see their own auth state unless they
+        # are admins, Hub admins often are also marked as admins so they
+        # will see their auth state but normal users won't
+        requestor = self.get_current_user()
+        if requestor.admin:
+            user_['auth_state'] = await user.get_auth_state()
+        self.write(json.dumps(user_))
 
     @admin_only
     async def post(self, name):
@@ -155,7 +163,7 @@ async def delete(self, name):
         self.set_status(204)
 
     @admin_only
-    def patch(self, name):
+    async def patch(self, name):
         user = self.find_user(name)
         if user is None:
             raise web.HTTPError(404)
@@ -166,9 +174,14 @@ def patch(self, name):
             if self.find_user(data['name']):
                 raise web.HTTPError(400, "User %s already exists, username must be unique" % data['name'])
         for key, value in data.items():
-            setattr(user, key, value)
+            if key == 'auth_state':
+                await user.save_auth_state(value)
+            else:
+                setattr(user, key, value)
         self.db.commit()
-        self.write(json.dumps(self.user_model(user)))
+        user_ = self.user_model(user)
+        user_['auth_state'] = await user.get_auth_state()
+        self.write(json.dumps(user_))
 
 
 class UserServerAPIHandler(APIHandler):


Test patch:
diff --git a/jupyterhub/tests/conftest.py b/jupyterhub/tests/conftest.py
--- a/jupyterhub/tests/conftest.py
+++ b/jupyterhub/tests/conftest.py
@@ -3,6 +3,7 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
 
+import os
 import logging
 from getpass import getuser
 from subprocess import TimeoutExpired
@@ -12,6 +13,7 @@
 from tornado import ioloop, gen
 
 from .. import orm
+from .. import crypto
 from ..utils import random_port
 
 from . import mocking
@@ -70,6 +72,23 @@ def fin():
     return mocked_app
 
 
+@fixture
+def auth_state_enabled(app):
+    app.authenticator.auth_state = {
+        'who': 'cares',
+    }
+    app.authenticator.enable_auth_state = True
+    ck = crypto.CryptKeeper.instance()
+    before_keys = ck.keys
+    ck.keys = [os.urandom(32)]
+    try:
+        yield
+    finally:
+        ck.keys = before_keys
+        app.authenticator.enable_auth_state = False
+        app.authenticator.auth_state = None
+
+
 # mock services for testing.
 # Shorter intervals, etc.
 class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
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
@@ -52,9 +52,13 @@ def new_func(app, *args, **kwargs):
     return new_func
 
 
-def find_user(db, name):
+def find_user(db, name, app=None):
     """Find user in database."""
-    return db.query(orm.User).filter(orm.User.name == name).first()
+    orm_user = db.query(orm.User).filter(orm.User.name == name).first()
+    if app is None:
+        return orm_user
+    else:
+        return app.users[orm_user.id]
 
 
 def add_user(db, app=None, **kwargs):
@@ -280,6 +284,8 @@ def test_get_user(app):
         'admin': False,
         'server': None,
         'pending': None,
+        # auth state is present because requestor is an admin
+        'auth_state': None
     }
 
 
@@ -420,6 +426,84 @@ def test_make_admin(app):
     assert user.admin
 
 
+@mark.user
+@mark.gen_test
+def test_set_auth_state(app, auth_state_enabled):
+    auth_state = {'secret': 'hello'}
+    db = app.db
+    name = 'admin'
+    user = find_user(db, name, app=app)
+    assert user is not None
+    assert user.name == name
+
+    r = yield api_request(app, 'users', name, method='patch',
+        data=json.dumps({'auth_state': auth_state})
+    )
+
+    assert r.status_code == 200
+    users_auth_state = yield user.get_auth_state()
+    assert users_auth_state == auth_state
+
+
+@mark.user
+@mark.gen_test
+def test_user_set_auth_state(app, auth_state_enabled):
+    auth_state = {'secret': 'hello'}
+    db = app.db
+    name = 'user'
+    user = find_user(db, name, app=app)
+    assert user is not None
+    assert user.name == name
+    user_auth_state = yield user.get_auth_state()
+    assert user_auth_state is None
+
+    r = yield api_request(
+        app, 'users', name, method='patch',
+        data=json.dumps({'auth_state': auth_state}),
+        headers=auth_header(app.db, name),
+    )
+
+    assert r.status_code == 403
+    user_auth_state = yield user.get_auth_state()
+    assert user_auth_state is None
+
+
+@mark.user
+@mark.gen_test
+def test_admin_get_auth_state(app, auth_state_enabled):
+    auth_state = {'secret': 'hello'}
+    db = app.db
+    name = 'admin'
+    user = find_user(db, name, app=app)
+    assert user is not None
+    assert user.name == name
+    yield user.save_auth_state(auth_state)
+
+    r = yield api_request(app, 'users', name)
+
+    assert r.status_code == 200
+    assert r.json()['auth_state'] == auth_state
+
+
+@mark.user
+@mark.gen_test
+def test_user_get_auth_state(app, auth_state_enabled):
+    # explicitly check that a user will not get their own auth state via the API
+    auth_state = {'secret': 'hello'}
+    db = app.db
+    name = 'user'
+    user = find_user(db, name, app=app)
+    assert user is not None
+    assert user.name == name
+    yield user.save_auth_state(auth_state)
+
+    r = yield api_request(app, 'users', name,
+                          headers=auth_header(app.db, name))
+
+    assert r.status_code == 200
+    assert 'auth_state' not in r.json()
+
+
 @mark.gen_test
 def test_spawn(app):
     db = app.db
@@ -593,7 +677,7 @@ def test_spawn_limit(app, no_patience, slow_spawn, request):
     user.spawner._start_future = Future()
     r = yield api_request(app, 'users', name, 'server', method='post')
     assert r.status_code == 429
-    
+
     # allow ykka to start
     users[0].spawner._start_future.set_result(None)
     # wait for ykka to finish
diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py
--- a/jupyterhub/tests/test_auth.py
+++ b/jupyterhub/tests/test_auth.py
@@ -22,7 +22,7 @@ def test_pam_auth():
         'password': 'match',
     })
     assert authorized['name'] == 'match'
-    
+
     authorized = yield authenticator.get_authenticated_user(None, {
         'username': 'match',
         'password': 'nomatch',
@@ -61,13 +61,13 @@ def test_pam_auth_whitelist():
         'password': 'kaylee',
     })
     assert authorized['name'] == 'kaylee'
-    
+
     authorized = yield authenticator.get_authenticated_user(None, {
         'username': 'wash',
         'password': 'nomatch',
     })
     assert authorized is None
-    
+
     authorized = yield authenticator.get_authenticated_user(None, {
         'username': 'mal',
         'password': 'mal',
@@ -85,9 +85,9 @@ def test_pam_auth_group_whitelist():
     g = MockGroup('kaylee')
     def getgrnam(name):
         return g
-    
+
     authenticator = MockPAMAuthenticator(group_whitelist={'group'})
-    
+
     with mock.patch.object(auth, 'getgrnam', getgrnam):
         authorized = yield authenticator.get_authenticated_user(None, {
             'username': 'kaylee',
@@ -128,21 +128,21 @@ def test_cant_add_system_user():
     authenticator = auth.PAMAuthenticator(whitelist={'mal'})
     authenticator.add_user_cmd = ['jupyterhub-fake-command']
     authenticator.create_system_users = True
-    
+
     class DummyFile:
         def read(self):
             return b'dummy error'
-    
+
     class DummyPopen:
         def __init__(self, *args, **kwargs):
             self.args = args
             self.kwargs = kwargs
             self.returncode = 1
             self.stdout = DummyFile()
-        
+
         def wait(self):
             return
-    
+
     with mock.patch.object(auth, 'Popen', DummyPopen):
         with pytest.raises(RuntimeError) as exc:
             yield authenticator.add_user(user)
@@ -196,23 +196,6 @@ def test_handlers(app):
     assert handlers[0][0] == '/login'
 
 
-@pytest.fixture
-def auth_state_enabled(app):
-    app.authenticator.auth_state = {
-        'who': 'cares',
-    }
-    app.authenticator.enable_auth_state = True
-    ck = crypto.CryptKeeper.instance()
-    before_keys = ck.keys
-    ck.keys = [os.urandom(32)]
-    try:
-        yield
-    finally:
-        ck.keys = before_keys
-        app.authenticator.enable_auth_state = False
-        app.authenticator.auth_state = None
-
-
 @pytest.mark.gen_test
 def test_auth_state(app, auth_state_enabled):
     """auth_state enabled and available"""
@@ -270,7 +253,7 @@ def test_auth_admin_retained_if_unset(app):
 @pytest.fixture
 def auth_state_unavailable(auth_state_enabled):
     """auth_state enabled at the Authenticator level,
-    
+
     but unavailable due to no crypto keys.
     """
     crypto.CryptKeeper.instance().keys = []
@@ -343,5 +326,3 @@ def test_validate_names():
     a = auth.PAMAuthenticator(username_pattern='w.*')
     assert not a.validate_username('xander')
     assert a.validate_username('willow')
-
-
diff --git a/jupyterhub/tests/test_named_servers.py b/jupyterhub/tests/test_named_servers.py
--- a/jupyterhub/tests/test_named_servers.py
+++ b/jupyterhub/tests/test_named_servers.py
@@ -36,6 +36,7 @@ def test_default_server(app, named_servers):
         'kind': 'user',
         'admin': False,
         'pending': None,
+        'auth_state': None,
         'server': user.url,
         'servers': {
             '': {
@@ -63,6 +64,7 @@ def test_default_server(app, named_servers):
         'pending': None,
         'server': None,
         'servers': {},
+        'auth_state': None,
     }
 
 
@@ -99,6 +101,7 @@ def test_create_named_server(app, named_servers):
         'kind': 'user',
         'admin': False,
         'pending': None,
+        'auth_state': None,
         'server': None,
         'servers': {
             servername: {
@@ -136,6 +139,7 @@ def test_delete_named_server(app, named_servers):
         'kind': 'user',
         'admin': False,
         'pending': None,
+        'auth_state': None,
         'server': None,
         'servers': {
             name: {