Skip to content

Commit 7cfbf77

Browse files
committed
consolidate cookie config on IdentityProvider
- JupyterHandler.cookie_name -> IdentityProvider.get_cookie_name(handler) - JupyterHandler.clear_login_cookie -> IdentityProvider.clear_login_cookie - JupyterHandler.force_clear_cookie -> IdentityProvider._force_clear_cookie - ServerApp.cookie_options -> IdentityProvider.cookie_options - ServerApp.get_secure_cookie_kwargs -> IdentityProvider.get_secure_cookie_kwargs - ServerApp.tornado_settings[secure_cookie] -> IdentityProvider.secure_cookie
1 parent 63c341a commit 7cfbf77

File tree

3 files changed

+149
-67
lines changed

3 files changed

+149
-67
lines changed

jupyter_server/auth/identity.py

+106-10
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
from __future__ import annotations
99

1010
import binascii
11+
import datetime
1112
import os
1213
import re
1314
import sys
1415
import uuid
1516
from dataclasses import asdict, dataclass
17+
from http.cookies import Morsel
1618
from typing import TYPE_CHECKING, Any, Awaitable
1719

18-
from tornado import web
20+
from tornado import escape, httputil, web
1921
from traitlets import Bool, Dict, Type, Unicode, default
2022
from traitlets.config import LoggingConfigurable
2123

@@ -28,6 +30,8 @@
2830
from jupyter_server.base.handlers import JupyterHandler
2931
from jupyter_server.serverapp import ServerApp
3032

33+
_non_alphanum = re.compile(r"[^A-Za-z0-9]")
34+
3135

3236
@dataclass
3337
class User:
@@ -120,7 +124,37 @@ class IdentityProvider(LoggingConfigurable):
120124
.. versionadded:: 2.0
121125
"""
122126

123-
cookie_name = Unicode(config=True)
127+
cookie_name = Unicode(
128+
"",
129+
config=True,
130+
help=_i18n("Name of the cookie to set for persisting login. Default: username-${Host}."),
131+
)
132+
133+
cookie_options = Dict(
134+
config=True,
135+
help=_i18n(
136+
"Extra keyword arguments to pass to `set_secure_cookie`."
137+
" See tornado's set_secure_cookie docs for details."
138+
),
139+
)
140+
141+
secure_cookie = Bool(
142+
None,
143+
allow_none=True,
144+
config=True,
145+
help=_i18n(
146+
"Specify whether login cookie should have the `secure` property (HTTPS-only)."
147+
"Only needed when protocol-detection gives the wrong answer due to proxies."
148+
),
149+
)
150+
151+
get_secure_cookie_kwargs = Dict(
152+
config=True,
153+
help=_i18n(
154+
"Extra keyword arguments to pass to `get_secure_cookie`."
155+
" See tornado's get_secure_cookie docs for details."
156+
),
157+
)
124158

125159
token = Unicode(
126160
"<generated>",
@@ -219,9 +253,11 @@ async def _get_user(self, handler: JupyterHandler) -> User | None:
219253
# If an invalid cookie was sent, clear it to prevent unnecessary
220254
# extra warnings. But don't do this on a request with *no* cookie,
221255
# because that can erroneously log you out (see gh-3365)
222-
if handler.get_cookie(handler.cookie_name) is not None:
223-
handler.log.warning("Clearing invalid/expired login cookie %s", handler.cookie_name)
224-
handler.clear_login_cookie()
256+
cookie_name = self.get_cookie_name(handler)
257+
cookie = handler.get_cookie(cookie_name)
258+
if cookie is not None:
259+
self.log.warning(f"Clearing invalid/expired login cookie {cookie_name}")
260+
self.clear_login_cookie(handler)
225261
if not self.auth_enabled:
226262
# Completely insecure! No authentication at all.
227263
# No need to warn here, though; validate_security will have already done that.
@@ -260,24 +296,84 @@ def user_from_cookie(self, cookie_value: str) -> User | None:
260296
"""Inverse of user_to_cookie"""
261297
return User(username=cookie_value)
262298

299+
def get_cookie_name(self, handler: JupyterHandler) -> str:
300+
"""Return the login cookie name
301+
302+
Uses IdentityProvider.cookie_name, if defined.
303+
Default is to generate a string taking host into account to avoid
304+
collisions for multiple servers on one hostname with different ports.
305+
"""
306+
if self.cookie_name:
307+
return self.cookie_name
308+
else:
309+
return _non_alphanum.sub("-", f"username-{handler.request.host}")
310+
263311
def set_login_cookie(self, handler: JupyterHandler, user: User) -> None:
264312
"""Call this on handlers to set the login cookie for success"""
265-
cookie_options = handler.settings.get("cookie_options", {})
313+
cookie_options = {}
314+
cookie_options.update(self.cookie_options)
266315
cookie_options.setdefault("httponly", True)
267316
# tornado <4.2 has a bug that considers secure==True as soon as
268317
# 'secure' kwarg is passed to set_secure_cookie
269-
if handler.settings.get("secure_cookie", handler.request.protocol == "https"):
318+
secure_cookie = self.secure_cookie
319+
if secure_cookie is None:
320+
secure_cookie = handler.request.protocol == "https"
321+
if secure_cookie:
270322
cookie_options.setdefault("secure", True)
271323
cookie_options.setdefault("path", handler.base_url)
272-
handler.set_secure_cookie(handler.cookie_name, self.user_to_cookie(user), **cookie_options)
324+
cookie_name = self.get_cookie_name(handler)
325+
handler.set_secure_cookie(cookie_name, self.user_to_cookie(user), **cookie_options)
326+
327+
def _force_clear_cookie(
328+
self, handler: JupyterHandler, name: str, path: str = "/", domain: str | None = None
329+
) -> None:
330+
"""Deletes the cookie with the given name.
331+
332+
Tornado's cookie handling currently (Jan 2018) stores cookies in a dict
333+
keyed by name, so it can only modify one cookie with a given name per
334+
response. The browser can store multiple cookies with the same name
335+
but different domains and/or paths. This method lets us clear multiple
336+
cookies with the same name.
337+
338+
Due to limitations of the cookie protocol, you must pass the same
339+
path and domain to clear a cookie as were used when that cookie
340+
was set (but there is no way to find out on the server side
341+
which values were used for a given cookie).
342+
"""
343+
name = escape.native_str(name)
344+
expires = datetime.datetime.utcnow() - datetime.timedelta(days=365)
345+
346+
morsel: Morsel = Morsel()
347+
morsel.set(name, "", '""')
348+
morsel["expires"] = httputil.format_timestamp(expires)
349+
morsel["path"] = path
350+
if domain:
351+
morsel["domain"] = domain
352+
handler.add_header("Set-Cookie", morsel.OutputString())
353+
354+
def clear_login_cookie(self, handler: JupyterHandler) -> None:
355+
"""Clear the login cookie, effectively logging out the session."""
356+
cookie_options = {}
357+
cookie_options.update(self.cookie_options)
358+
path = cookie_options.setdefault("path", self.base_url)
359+
cookie_name = self.get_cookie_name(handler)
360+
handler.clear_cookie(cookie_name, path=path)
361+
if path and path != "/":
362+
# also clear cookie on / to ensure old cookies are cleared
363+
# after the change in path behavior.
364+
# N.B. This bypasses the normal cookie handling, which can't update
365+
# two cookies with the same name. See the method above.
366+
self._force_clear_cookie(handler, cookie_name)
273367

274368
def get_user_cookie(self, handler: JupyterHandler) -> User | None | Awaitable[User | None]:
275369
"""Get user from a cookie
276370
277371
Calls user_from_cookie to deserialize cookie value
278372
"""
279-
get_secure_cookie_kwargs = handler.settings.get("get_secure_cookie_kwargs", {})
280-
_user_cookie = handler.get_secure_cookie(handler.cookie_name, **get_secure_cookie_kwargs)
373+
_user_cookie = handler.get_secure_cookie(
374+
self.get_cookie_name(handler),
375+
**self.get_secure_cookie_kwargs,
376+
)
281377
if not _user_cookie:
282378
return None
283379
user_cookie = _user_cookie.decode()

jupyter_server/base/handlers.py

+28-41
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
# Distributed under the terms of the Modified BSD License.
44
from __future__ import annotations
55

6-
import datetime
76
import functools
87
import inspect
98
import ipaddress
@@ -15,14 +14,13 @@
1514
import types
1615
import warnings
1716
from http.client import responses
18-
from http.cookies import Morsel
1917
from typing import TYPE_CHECKING, Awaitable
2018
from urllib.parse import urlparse
2119

2220
import prometheus_client
2321
from jinja2 import TemplateNotFound
2422
from jupyter_core.paths import is_hidden
25-
from tornado import escape, httputil, web
23+
from tornado import web
2624
from tornado.log import app_log
2725
from traitlets.config import Application
2826

@@ -46,7 +44,6 @@
4644
# -----------------------------------------------------------------------------
4745
# Top-level handlers
4846
# -----------------------------------------------------------------------------
49-
non_alphanum = re.compile(r"[^A-Za-z0-9]")
5047

5148
_sys_info_cache = None
5249

@@ -104,41 +101,36 @@ def set_default_headers(self):
104101
# for example, so just ignore)
105102
self.log.debug(e)
106103

107-
def force_clear_cookie(self, name, path="/", domain=None):
108-
"""Deletes the cookie with the given name.
109-
110-
Tornado's cookie handling currently (Jan 2018) stores cookies in a dict
111-
keyed by name, so it can only modify one cookie with a given name per
112-
response. The browser can store multiple cookies with the same name
113-
but different domains and/or paths. This method lets us clear multiple
114-
cookies with the same name.
115-
116-
Due to limitations of the cookie protocol, you must pass the same
117-
path and domain to clear a cookie as were used when that cookie
118-
was set (but there is no way to find out on the server side
119-
which values were used for a given cookie).
120-
"""
121-
name = escape.native_str(name)
122-
expires = datetime.datetime.utcnow() - datetime.timedelta(days=365)
104+
@property
105+
def cookie_name(self):
106+
warnings.warn(
107+
"""JupyterHandler.login_handler is deprecated in 2.0,
108+
use JupyterHandler.identity_provider.
109+
""",
110+
DeprecationWarning,
111+
stacklevel=2,
112+
)
113+
return self.identity_provider.get_cookie_name(self)
123114

124-
morsel: Morsel = Morsel()
125-
morsel.set(name, "", '""')
126-
morsel["expires"] = httputil.format_timestamp(expires)
127-
morsel["path"] = path
128-
if domain:
129-
morsel["domain"] = domain
130-
self.add_header("Set-Cookie", morsel.OutputString())
115+
def force_clear_cookie(self, name, path="/", domain=None):
116+
warnings.warn(
117+
"""JupyterHandler.login_handler is deprecated in 2.0,
118+
use JupyterHandler.identity_provider.
119+
""",
120+
DeprecationWarning,
121+
stacklevel=2,
122+
)
123+
return self.identity_provider._force_clear_cookie(self, name, path=path, domain=domain)
131124

132125
def clear_login_cookie(self):
133-
cookie_options = self.settings.get("cookie_options", {})
134-
path = cookie_options.setdefault("path", self.base_url)
135-
self.clear_cookie(self.cookie_name, path=path)
136-
if path and path != "/":
137-
# also clear cookie on / to ensure old cookies are cleared
138-
# after the change in path behavior.
139-
# N.B. This bypasses the normal cookie handling, which can't update
140-
# two cookies with the same name. See the method above.
141-
self.force_clear_cookie(self.cookie_name)
126+
warnings.warn(
127+
"""JupyterHandler.login_handler is deprecated in 2.0,
128+
use JupyterHandler.identity_provider.
129+
""",
130+
DeprecationWarning,
131+
stacklevel=2,
132+
)
133+
return self.identity_provider.clear_login_cookie(self)
142134

143135
def get_current_user(self):
144136
clsname = self.__class__.__name__
@@ -173,11 +165,6 @@ def token_authenticated(self):
173165
"""Have I been authenticated with a token?"""
174166
return self.identity_provider.is_token_authenticated(self)
175167

176-
@property
177-
def cookie_name(self):
178-
default_cookie_name = non_alphanum.sub("-", f"username-{self.request.host}")
179-
return self.settings.get("cookie_name", default_cookie_name)
180-
181168
@property
182169
def logged_in(self):
183170
"""Is a user currently logged in?"""

jupyter_server/serverapp.py

+15-16
Original file line numberDiff line numberDiff line change
@@ -1143,12 +1143,8 @@ def _warn_deprecated_config(self, change, clsname, new_name=None):
11431143
def _deprecated_password(self, change):
11441144
self._warn_deprecated_config(change, "PasswordIdentityProvider", new_name="hashed_password")
11451145

1146-
@observe("password_required")
1147-
def _deprecated_password_required(self, change):
1148-
self._warn_deprecated_config(change, "PasswordIdentityProvider")
1149-
1150-
@observe("allow_password_change")
1151-
def _deprecated_password_change(self, change):
1146+
@observe("password_required", "allow_password_change")
1147+
def _deprecated_password_config(self, change):
11521148
self._warn_deprecated_config(change, "PasswordIdentityProvider")
11531149

11541150
disable_check_xsrf = Bool(
@@ -1318,18 +1314,17 @@ def _default_allow_remote(self):
13181314

13191315
cookie_options = Dict(
13201316
config=True,
1321-
help=_i18n(
1322-
"Extra keyword arguments to pass to `set_secure_cookie`."
1323-
" See tornado's set_secure_cookie docs for details."
1324-
),
1317+
help=_i18n("DEPRECATED. Use IdentityProvider.cookie_options"),
13251318
)
13261319
get_secure_cookie_kwargs = Dict(
13271320
config=True,
1328-
help=_i18n(
1329-
"Extra keyword arguments to pass to `get_secure_cookie`."
1330-
" See tornado's get_secure_cookie docs for details."
1331-
),
1321+
help=_i18n("DEPRECATED. Use IdentityProvider.get_secure_cookie_kwargs"),
13321322
)
1323+
1324+
@observe("cookie_options", "get_secure_cookie_kwargs")
1325+
def _deprecated_cookie_config(self, change):
1326+
self._warn_deprecated_config(change, "IdentityProvider")
1327+
13331328
ssl_options = Dict(
13341329
allow_none=True,
13351330
config=True,
@@ -1947,8 +1942,12 @@ def init_webapp(self):
19471942
self.tornado_settings["allow_origin_pat"] = re.compile(self.allow_origin_pat)
19481943
self.tornado_settings["allow_credentials"] = self.allow_credentials
19491944
self.tornado_settings["autoreload"] = self.autoreload
1950-
self.tornado_settings["cookie_options"] = self.cookie_options
1951-
self.tornado_settings["get_secure_cookie_kwargs"] = self.get_secure_cookie_kwargs
1945+
1946+
# deprecate accessing these directly, in favor of identity_provider?
1947+
self.tornado_settings["cookie_options"] = self.identity_provider.cookie_options
1948+
self.tornado_settings[
1949+
"get_secure_cookie_kwargs"
1950+
] = self.identity_provider.get_secure_cookie_kwargs
19521951
self.tornado_settings["token"] = self.identity_provider.token
19531952

19541953
# ensure default_url starts with base_url

0 commit comments

Comments
 (0)