mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-17 06:52:59 +00:00
Add token_expires_in_max_seconds configuration
Allows limiting max expiration of tokens created via the API Only affects the POST /api/tokens endpoint, not tokens issued by other means or created prior to config
This commit is contained in:
@@ -489,10 +489,29 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
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.get("token_expires_in_max_seconds", 0)
|
||||
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,
|
||||
)
|
||||
|
@@ -464,6 +464,26 @@ class JupyterHub(Application):
|
||||
# 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 @@ class JupyterHub(Application):
|
||||
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,
|
||||
|
@@ -542,11 +542,50 @@ class TokenPageHandler(BaseHandler):
|
||||
oauth_clients = sorted(oauth_clients, key=sort_key, reverse=True)
|
||||
|
||||
auth_state = await self.current_user.get_auth_state()
|
||||
expires_in_max = self.settings.get("token_expires_in_max_seconds", 0)
|
||||
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)
|
||||
|
||||
|
@@ -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()
|
||||
|
@@ -12,6 +12,7 @@ from unittest import mock
|
||||
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",
|
||||
[
|
||||
|
@@ -13,13 +13,7 @@
|
||||
<br />
|
||||
<label for="token-expiration-seconds" class="form-label">Token expires in</label>
|
||||
{% block expiration_options %}
|
||||
<select id="token-expiration-seconds" class="form-select">
|
||||
<!-- unit used for each value is `seconds` -->
|
||||
<option value="3600">1 Hour</option>
|
||||
<option value="86400">1 Day</option>
|
||||
<option value="604800">1 Week</option>
|
||||
<option value="" selected="selected">Never</option>
|
||||
</select>
|
||||
<select id="token-expiration-seconds" class="form-select">{{ token_expires_in_options_html | safe }}</select>
|
||||
{% endblock expiration_options %}
|
||||
<small id="note-expires-at" class="form-text">You can configure when your token will expire.</small>
|
||||
<br />
|
||||
|
Reference in New Issue
Block a user