Repository: jupyterhub/jupyterhub

Issue:
Expire user generated tokens at /hub/token
Similar to `config.JupyterHub.cookie_max_age_days` could be `config.JupyterHub.user_tokens_max_age_days` with default to the current state `0` == no expiration.
Controls for JupyterHub API tokens
JupyterHub has a [REST API](https://jupyterhub.readthedocs.io/en/stable/reference/rest.html) that can be accessed using an API token.  The API token can be created at in the Hub UI at `/hub/token` or through a [POST](https://jupyterhub.readthedocs.io/en/stable/reference/rest-api.html#/default/post_users__name__tokens) to the REST API itself.  In the Hub UI a user can create a token with expiration of 1 day, 1 week, 1 month, or never.  The API allows a user to set an arbitrary value for expiration.  A user's tokens can be revoked by the user or by a user with the ability to revoke their tokens (e.g. an admin).

I was wondering what controls JupyterHub admins have or could have to mitigate risk from lost or stolen JupyterHub REST API tokens.  I'm opening this issue to capture discussion about potential controls to implement in JupyterHub, or that may already be available.  Some of this has been scoped out with @minrk.

In terms of remediation, if a user's token is known to be compromised, their accounts are possibly already locked and they may not have a way to revoke their own tokens.  If an admin is informed that a user's token is compromised, they can delete the user from their Hub, use `python -m jupyterhub.dbutil shell` to remove the token, or make an API call to revoke the user's token.  It may be a good idea to add to the Hub UI some way for users who can revoke other users' tokens a way to do that.  This could be done by updating the URL scheme so such a user (e.g. an admin) can see another user's `/hub/token` page to do the revocation.

In terms of more preemptive mitigations, might there be ways for admins to:

1. Disable generation of these general-purpose tokens altogether?
2. Enable generation of only more restrictively scoped tokens (a more restricted user role)?
3. Enforce a maximum lifetime for these general-purpose tokens, necessitating a re-auth?
4. Only allow token access from specified IP ranges?

Option 1 works already through JupyterHub RBAC by replacing the default `user` role with one that cannot create API tokens.  Thanks to Min for enumerating all the scopes here:

```
c.JupyterHub.load_roles = [
    {
        "name": "user",
        "scopes": [
            # read the user model
            "read:users!user",
            # manage the user's servers
            "servers!user",
            # access the user's servers
            "access:servers!user",
            # update activity (required for server activity)
            "users:activity!user",
            # keep read-only access to tokens
            # can't add or revoke tokens!
            "read:tokens!user",
        ]
    }
]
```

With this redefined `user` role, attempts to create a token fail with 403 Forbidden.  Other tokens that are needed to operate notebooks and services still work just fine.  This solution seems like a good one for folks who need to require their users to re-authenticate periodically to interact with Jupyter/JupyterHub.  This may be worth including as documentation via example, and referencing that example from JupyterHub's RBAC and security docs?

Option 2 is a little bit different from the above which just shuts off the ability to generate tokens.  There might be some argument that users could have the ability to generate more restricted tokens and that's somehow OK.  Are there use cases where that makes sense, and what kind of interface would be needed to manage all the different things you could pick and choose?

Option 3 is something that is not possible now technically (this has been discussed previously in #1772 as well).  It is something Min says he would like to be able to address as part of a better singleuser experience.

I went into this favoring Option 4 personally, just allowing users to use tokens from specified IP ranges, but Min pointed out that mileage may vary due to issues with reverse proxying.  If one were to try this it may just be best to do it at the edge or where SSL is terminated.

Do folks have other thoughts, opinions, or other options?


Discussion:
0.9 adds timestamps (created and last_activity) to API tokens, so this should now be possible. I'm reticent to do it by default without adding refresh tokens, because it would mean that single-user servers would have a max uptime of token_max_age_days.
Thanks. So far I'm not sure how to handle an employee living the company for instance. i.e. even though they can't login anymore the generated tokens would still be valid.
Hi all,
I'm a little new to jupyterhub inner workings so please forgive my ignorance in advance.

Would this change affect the `jupyter-hub-token` cookie as well as `user-*` cookies? Also to my knowledge the `set_login_cookie` mehtod does not expose passing all kwargs to tornado's `set_secure_cookie` method. Can we also fit this in in a nice way?

My use case involves only setting the expiry date to a lot shorter than the 30 days standard one. I got it to work with some local modifications, but it's obviously not something anyone would like to manage over time and it's mandatory to have it for my organization's single-sign-on. 

We're using jupyterhub 0.8.1 if that helps.
Hi, how are other JupyterHub installations dealing with this in the enterprise?
does someone has newer feedback?

Any news on how to do this?
This method is still patched on our installation and it's pretty ugly. 
Adding a config option described in OP for default token max age with a default preserving the current behavior makes sense to me in the short term. Doing so at the moment does mean that you are setting a max age of user servers before they must be forced to restart in order to get a fresh token, but for many deployments that's likely not an issue, so I think it's okay to add the option with this caveat.

Then we can turn it on by default perhaps when someone gets a chance to implement refresh token support.
Thanks for writing this up!

Allowing users to manage other users' tokens (assuming they have permission) should be pretty straightforward. The API already does this and the UI behind `/hub/token` should be pretty easy to update to support `/hub/token/:user`.

I think there are other ways we can make token revocation easier (it can only be done now by token _id_, not if you only have the token itself - which means you need to find out the token id you want to revoke!). I think writing up some sample use cases to cover desirable workflows will help.

I think options for default and maximum expiry are fairly easy to add. Until/unless we have refresh tokens in place and a good experience for refreshing tokens when talking to a user server during long sessions, token max age should be at least _server_ max age. Because if a server's own API token expires, things start to break in a way that can only be solved by server restart. And if a user's browser token expires, that can result in data loss due to losing the ability to save without refreshing the page.

For securing access from a location, I would favor using internal_ssl to lockdown internal, local communication, and validate ips at the edge. Because inside the Hub has to handle too many different layers of proxy headers, etc., and we already haven't been able to get this robustly correct for just the protocol of the original request.

Resolving patch:
diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py
--- a/jupyterhub/apihandlers/users.py
+++ b/jupyterhub/apihandlers/users.py
@@ -489,10 +489,29 @@ async def post(self, user_name):
                     400, f"token {key} must be null or a list of strings, not {value!r}"
                 )
 
+        expires_in = body.get('expires_in', None)
+        if not (expires_in is None or isinstance(expires_in, int)):
+            raise web.HTTPError(
+                400,
+                f"token expires_in must be null or integer, not {expires_in!r}",
+            )
+        expires_in_max = self.settings["token_expires_in_max_seconds"]
+        if expires_in_max:
+            # validate expires_in against limit
+            if expires_in is None:
+                # expiration unspecified, use max value
+                # (default before max limit was introduced was 'never', this is closest equivalent)
+                expires_in = expires_in_max
+            elif expires_in > expires_in_max:
+                raise web.HTTPError(
+                    400,
+                    f"token expires_in: {expires_in} must not exceed {expires_in_max}",
+                )
+
         try:
             api_token = user.new_api_token(
                 note=note,
-                expires_in=body.get('expires_in', None),
+                expires_in=expires_in,
                 roles=token_roles,
                 scopes=token_scopes,
             )
diff --git a/jupyterhub/app.py b/jupyterhub/app.py
--- a/jupyterhub/app.py
+++ b/jupyterhub/app.py
@@ -464,6 +464,26 @@ def _cookie_max_age_seconds(self):
         # convert cookie max age days to seconds
         return int(self.cookie_max_age_days * 24 * 3600)
 
+    token_expires_in_max_seconds = Integer(
+        0,
+        config=True,
+        help="""
+        Set the maximum expiration (in seconds) of tokens created via the API.
+
+        Set to any positive value to disallow creation of tokens with no expiration.
+
+        0 (default) = no limit.
+        
+        Does not affect:
+        
+        - Server API tokens ($JUPYTERHUB_API_TOKEN is tied to lifetime of the server)
+        - Tokens issued during oauth (use `oauth_token_expires_in`)
+        - Tokens created via the API before configuring this limit
+        
+        .. versionadded:: 5.1
+        """,
+    )
+
     redirect_to_server = Bool(
         True, help="Redirect user to server (if running), instead of control panel."
     ).tag(config=True)
@@ -3192,6 +3212,7 @@ def init_tornado_settings(self):
             static_path=os.path.join(self.data_files_path, 'static'),
             static_url_prefix=url_path_join(self.hub.base_url, 'static/'),
             static_handler_class=CacheControlStaticFilesHandler,
+            token_expires_in_max_seconds=self.token_expires_in_max_seconds,
             subdomain_hook=self.subdomain_hook,
             template_path=self.template_paths,
             template_vars=self.template_vars,
diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py
--- a/jupyterhub/handlers/pages.py
+++ b/jupyterhub/handlers/pages.py
@@ -542,11 +542,50 @@ def sort_key(client):
         oauth_clients = sorted(oauth_clients, key=sort_key, reverse=True)
 
         auth_state = await self.current_user.get_auth_state()
+        expires_in_max = self.settings["token_expires_in_max_seconds"]
+        options = [
+            (3600, "1 Hour"),
+            (86400, "1 Day"),
+            (7 * 86400, "1 Week"),
+            (30 * 86400, "1 Month"),
+            (365 * 86400, "1 Year"),
+        ]
+        if expires_in_max:
+            # omit items that exceed the limit
+            options = [
+                (seconds, label)
+                for (seconds, label) in options
+                if seconds <= expires_in_max
+            ]
+            if expires_in_max not in (seconds for (seconds, label) in options):
+                # max not exactly in list, add it
+                # this also ensures options_list is never empty
+                max_hours = expires_in_max / 3600
+                max_days = max_hours / 24
+                if max_days < 3:
+                    max_label = f"{max_hours:.0f} hours"
+                else:
+                    # this could be a lot of days, but no need to get fancy
+                    max_label = f"{max_days:.0f} days"
+                options.append(("", f"Max ({max_label})"))
+        else:
+            options.append(("", "Never"))
+
+        options_html_elements = [
+            f'<option value="{value}">{label}</option>' for value, label in options
+        ]
+        # make the last item selected
+        options_html_elements[-1] = options_html_elements[-1].replace(
+            "<option ", '<option selected="selected"'
+        )
+        expires_in_options_html = "\n".join(options_html_elements)
         html = await self.render_template(
             'token.html',
             api_tokens=api_tokens,
             oauth_clients=oauth_clients,
             auth_state=auth_state,
+            token_expires_in_options_html=expires_in_options_html,
+            token_expires_in_max_seconds=expires_in_max,
         )
         self.finish(html)
 


Test patch:
diff --git a/jupyterhub/tests/browser/test_browser.py b/jupyterhub/tests/browser/test_browser.py
--- a/jupyterhub/tests/browser/test_browser.py
+++ b/jupyterhub/tests/browser/test_browser.py
@@ -481,6 +481,70 @@ async def open_token_page(app, browser, user):
     await expect(browser).to_have_url(re.compile(".*/hub/token"))
 
 
+@pytest.mark.parametrize(
+    "expires_in_max, expected_options",
+    [
+        pytest.param(
+            None,
+            [
+                ('1 Hour', '3600'),
+                ('1 Day', '86400'),
+                ('1 Week', '604800'),
+                ('1 Month', '2592000'),
+                ('1 Year', '31536000'),
+                ('Never', ''),
+            ],
+            id="default",
+        ),
+        pytest.param(
+            86400,
+            [
+                ('1 Hour', '3600'),
+                ('1 Day', '86400'),
+            ],
+            id="1day",
+        ),
+        pytest.param(
+            3600 * 36,
+            [
+                ('1 Hour', '3600'),
+                ('1 Day', '86400'),
+                ('Max (36 hours)', ''),
+            ],
+            id="36hours",
+        ),
+        pytest.param(
+            86400 * 10,
+            [
+                ('1 Hour', '3600'),
+                ('1 Day', '86400'),
+                ('1 Week', '604800'),
+                ('Max (10 days)', ''),
+            ],
+            id="10days",
+        ),
+    ],
+)
+async def test_token_form_expires_in(
+    app, browser, user_special_chars, expires_in_max, expected_options
+):
+    with mock.patch.dict(
+        app.tornado_settings, {"token_expires_in_max_seconds": expires_in_max}
+    ):
+        await open_token_page(app, browser, user_special_chars.user)
+    # check the list of tokens duration
+    dropdown = browser.locator('#token-expiration-seconds')
+    options = await dropdown.locator('option').all()
+    actual_values = [
+        (await option.text_content(), await option.get_attribute('value'))
+        for option in options
+    ]
+    assert actual_values == expected_options
+    # get the value of the 'selected' attribute of the currently selected option
+    selected_value = dropdown.locator('option[selected]')
+    await expect(selected_value).to_have_text(expected_options[-1][0])
+
+
 async def test_token_request_form_and_panel(app, browser, user_special_chars):
     """verify elements of the request token form"""
 
@@ -497,24 +561,6 @@ async def test_token_request_form_and_panel(app, browser, user_special_chars):
     await expect(field_note).to_be_enabled()
     await expect(field_note).to_be_empty()
 
-    # check the list of tokens duration
-    dropdown = browser.locator('#token-expiration-seconds')
-    options = await dropdown.locator('option').all()
-    expected_values_in_list = {
-        '1 Hour': '3600',
-        '1 Day': '86400',
-        '1 Week': '604800',
-        'Never': '',
-    }
-    actual_values = {
-        await option.text_content(): await option.get_attribute('value')
-        for option in options
-    }
-    assert actual_values == expected_values_in_list
-    # get the value of the 'selected' attribute of the currently selected option
-    selected_value = dropdown.locator('option[selected]')
-    await expect(selected_value).to_have_text("Never")
-
     # check scopes field
     scopes_input = browser.get_by_label("Permissions")
     await expect(scopes_input).to_be_editable()
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
@@ -12,6 +12,7 @@
 from urllib.parse import parse_qs, quote, urlparse
 
 import pytest
+from dateutil.parser import parse as parse_date
 from pytest import fixture, mark
 from tornado.httputil import url_concat
 
@@ -1726,6 +1727,46 @@ async def test_get_new_token(app, headers, status, note, expires_in):
     assert r.status_code == 404
 
 
+@pytest.mark.parametrize(
+    "expires_in_max, expires_in, expected",
+    [
+        (86400, None, 86400),
+        (86400, 86400, 86400),
+        (86400, 86401, 'error'),
+        (3600, 100, 100),
+        (None, None, None),
+        (None, 86400, 86400),
+    ],
+)
+async def test_token_expires_in_max(app, user, expires_in_max, expires_in, expected):
+    options = {
+        "expires_in": expires_in,
+    }
+    # request a new token
+    with mock.patch.dict(
+        app.tornado_settings, {"token_expires_in_max_seconds": expires_in_max}
+    ):
+        r = await api_request(
+            app,
+            f'users/{user.name}/tokens',
+            method='post',
+            data=json.dumps(options),
+        )
+    if expected == 'error':
+        assert r.status_code == 400
+        assert f"must not exceed {expires_in_max}" in r.json()["message"]
+        return
+    else:
+        assert r.status_code == 201
+    token_model = r.json()
+    if expected is None:
+        assert token_model["expires_at"] is None
+    else:
+        expected_expires_at = utcnow() + timedelta(seconds=expected)
+        expires_at = parse_date(token_model["expires_at"])
+        assert abs((expires_at - expected_expires_at).total_seconds()) < 30
+
+
 @mark.parametrize(
     "as_user, for_user, status",
     [