1414import warnings
1515from http .client import responses
1616from logging import Logger
17- from typing import TYPE_CHECKING , Any , Awaitable , Sequence , cast
17+ from typing import TYPE_CHECKING , Any , Awaitable , Coroutine , Sequence , cast
1818from urllib .parse import urlparse
1919
2020import prometheus_client
2929from jupyter_server import CallContext
3030from jupyter_server ._sysinfo import get_sys_info
3131from jupyter_server ._tz import utcnow
32- from jupyter_server .auth .decorator import authorized
32+ from jupyter_server .auth .decorator import allow_unauthenticated , authorized
3333from jupyter_server .auth .identity import User
3434from jupyter_server .i18n import combine_translations
3535from jupyter_server .services .security import csp_report_uri
@@ -589,7 +589,7 @@ def check_host(self) -> bool:
589589 )
590590 return allow
591591
592- async def prepare (self ) -> Awaitable [None ] | None : # type:ignore[override]
592+ async def prepare (self , * , _redirect_to_login = True ) -> Awaitable [None ] | None : # type:ignore[override]
593593 """Prepare a response."""
594594 # Set the current Jupyter Handler context variable.
595595 CallContext .set (CallContext .JUPYTER_HANDLER , self )
@@ -630,6 +630,25 @@ async def prepare(self) -> Awaitable[None] | None: # type:ignore[override]
630630 self .set_cors_headers ()
631631 if self .request .method not in {"GET" , "HEAD" , "OPTIONS" }:
632632 self .check_xsrf_cookie ()
633+
634+ if not self .settings .get ("allow_unauthenticated_access" , False ):
635+ if not self .request .method :
636+ raise HTTPError (403 )
637+ method = getattr (self , self .request .method .lower ())
638+ if not getattr (method , "__allow_unauthenticated" , False ):
639+ if _redirect_to_login :
640+ # reuse `web.authenticated` logic, which redirects to the login
641+ # page on GET and HEAD and otherwise raises 403
642+ return web .authenticated (lambda _ : super ().prepare ())(self )
643+ else :
644+ # raise 403 if user is not known without redirecting to login page
645+ user = self .current_user
646+ if user is None :
647+ self .log .warning (
648+ f"Couldn't authenticate { self .__class__ .__name__ } connection"
649+ )
650+ raise web .HTTPError (403 )
651+
633652 return super ().prepare ()
634653
635654 # ---------------------------------------------------------------
@@ -726,7 +745,7 @@ def write_error(self, status_code: int, **kwargs: Any) -> None:
726745class APIHandler (JupyterHandler ):
727746 """Base class for API handlers"""
728747
729- async def prepare (self ) -> None :
748+ async def prepare (self ) -> None : # type:ignore[override]
730749 """Prepare an API response."""
731750 await super ().prepare ()
732751 if not self .check_origin ():
@@ -794,6 +813,7 @@ def finish(self, *args: Any, **kwargs: Any) -> Future[Any]:
794813 self .set_header ("Content-Type" , set_content_type )
795814 return super ().finish (* args , ** kwargs )
796815
816+ @allow_unauthenticated
797817 def options (self , * args : Any , ** kwargs : Any ) -> None :
798818 """Get the options."""
799819 if "Access-Control-Allow-Headers" in self .settings .get ("headers" , {}):
@@ -837,7 +857,7 @@ def options(self, *args: Any, **kwargs: Any) -> None:
837857class Template404 (JupyterHandler ):
838858 """Render our 404 template"""
839859
840- async def prepare (self ) -> None :
860+ async def prepare (self ) -> None : # type:ignore[override]
841861 """Prepare a 404 response."""
842862 await super ().prepare ()
843863 raise web .HTTPError (404 )
@@ -1002,6 +1022,18 @@ def compute_etag(self) -> str | None:
10021022 """Compute the etag."""
10031023 return None
10041024
1025+ # access is allowed as this class is used to serve static assets on login page
1026+ # TODO: create an allow-list of files used on login page and remove this decorator
1027+ @allow_unauthenticated
1028+ def get (self , path : str , include_body : bool = True ) -> Coroutine [Any , Any , None ]:
1029+ return super ().get (path , include_body )
1030+
1031+ # access is allowed as this class is used to serve static assets on login page
1032+ # TODO: create an allow-list of files used on login page and remove this decorator
1033+ @allow_unauthenticated
1034+ def head (self , path : str ) -> Awaitable [None ]:
1035+ return super ().head (path )
1036+
10051037 @classmethod
10061038 def get_absolute_path (cls , roots : Sequence [str ], path : str ) -> str :
10071039 """locate a file to serve on our static file search path"""
@@ -1036,6 +1068,7 @@ class APIVersionHandler(APIHandler):
10361068
10371069 _track_activity = False
10381070
1071+ @allow_unauthenticated
10391072 def get (self ) -> None :
10401073 """Get the server version info."""
10411074 # not authenticated, so give as few info as possible
@@ -1048,6 +1081,7 @@ class TrailingSlashHandler(web.RequestHandler):
10481081 This should be the first, highest priority handler.
10491082 """
10501083
1084+ @allow_unauthenticated
10511085 def get (self ) -> None :
10521086 """Handle trailing slashes in a get."""
10531087 assert self .request .uri is not None
@@ -1064,6 +1098,7 @@ def get(self) -> None:
10641098class MainHandler (JupyterHandler ):
10651099 """Simple handler for base_url."""
10661100
1101+ @allow_unauthenticated
10671102 def get (self ) -> None :
10681103 """Get the main template."""
10691104 html = self .render_template ("main.html" )
@@ -1104,18 +1139,20 @@ async def redirect_to_files(self: Any, path: str) -> None:
11041139 self .log .debug ("Redirecting %s to %s" , self .request .path , url )
11051140 self .redirect (url )
11061141
1142+ @allow_unauthenticated
11071143 async def get (self , path : str = "" ) -> None :
11081144 return await self .redirect_to_files (self , path )
11091145
11101146
11111147class RedirectWithParams (web .RequestHandler ):
1112- """Sam as web.RedirectHandler, but preserves URL parameters"""
1148+ """Same as web.RedirectHandler, but preserves URL parameters"""
11131149
11141150 def initialize (self , url : str , permanent : bool = True ) -> None :
11151151 """Initialize a redirect handler."""
11161152 self ._url = url
11171153 self ._permanent = permanent
11181154
1155+ @allow_unauthenticated
11191156 def get (self ) -> None :
11201157 """Get a redirect."""
11211158 sep = "&" if "?" in self ._url else "?"
@@ -1128,6 +1165,7 @@ class PrometheusMetricsHandler(JupyterHandler):
11281165 Return prometheus metrics for this server
11291166 """
11301167
1168+ @allow_unauthenticated
11311169 def get (self ) -> None :
11321170 """Get prometheus metrics."""
11331171 if self .settings ["authenticate_prometheus" ] and not self .logged_in :
@@ -1137,6 +1175,18 @@ def get(self) -> None:
11371175 self .write (prometheus_client .generate_latest (prometheus_client .REGISTRY ))
11381176
11391177
1178+ class PublicStaticFileHandler (web .StaticFileHandler ):
1179+ """Same as web.StaticFileHandler, but decorated to acknowledge that auth is not required."""
1180+
1181+ @allow_unauthenticated
1182+ def head (self , path : str ) -> Awaitable [None ]:
1183+ return super ().head (path )
1184+
1185+ @allow_unauthenticated
1186+ def get (self , path : str , include_body : bool = True ) -> Coroutine [Any , Any , None ]:
1187+ return super ().get (path , include_body )
1188+
1189+
11401190# -----------------------------------------------------------------------------
11411191# URL pattern fragments for reuse
11421192# -----------------------------------------------------------------------------
@@ -1152,6 +1202,6 @@ def get(self) -> None:
11521202default_handlers = [
11531203 (r".*/" , TrailingSlashHandler ),
11541204 (r"api" , APIVersionHandler ),
1155- (r"/(robots\.txt|favicon\.ico)" , web . StaticFileHandler ),
1205+ (r"/(robots\.txt|favicon\.ico)" , PublicStaticFileHandler ),
11561206 (r"/metrics" , PrometheusMetricsHandler ),
11571207]
0 commit comments