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:
Min RK
2024-06-03 12:50:07 +02:00
parent d8bb3f4402
commit df4f96eaf9
6 changed files with 186 additions and 26 deletions

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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()

View File

@@ -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",
[

View File

@@ -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 />