Skip to content

Commit

Permalink
WIP: add /api/me to get identity model
Browse files Browse the repository at this point in the history
includes fields:

- username: str
- name: Optional[str]
- display_name: Optional[str]
- initials: Optional[str]
- avatar_url: Optional[str]
- color: Optional[str]
- permissions in the form {"resource": ["action", ],}

where permissions are only populated _by request_,
because the server cannot know what all resource/action combinations are available.

Defines new jupyter_server.auth.IdentityProvider API for implementing authorization

- IdP.get_user(Handler) returns opaque truthy user for authenticated requests or None
- IdP.user_model adapts opaque User to standard identity model
  • Loading branch information
minrk committed Feb 10, 2022
1 parent 49dfe2e commit 2f47b2e
Show file tree
Hide file tree
Showing 6 changed files with 278 additions and 16 deletions.
1 change: 1 addition & 0 deletions jupyter_server/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .authorizer import * # noqa
from .decorator import authorized # noqa
from .identity import * # noqa
from .security import passwd # noqa
154 changes: 154 additions & 0 deletions jupyter_server/auth/identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Identity Provider interface
This defines the _authentication_ layer of Jupyter Server,
to be used in combination with Authorizer for _authorization_.
.. versionadded:: 2.0
"""
import sys
from typing import Any
from typing import Dict
from typing import List
from typing import Optional

from tornado.web import RequestHandler
from traitlets.config import LoggingConfigurable

if sys.version_info >= (3, 8):
from typing import TypedDict
else:
try:
from typing_extensions import TypedDict
except ImportError:
TypedDict = Dict


class IdentityModel(TypedDict):
# see the JupyterLab IUser model for definitions

username: str # the only truly required field

# these fields are derived from username if not specified
name: str
display_name: str

# these fields are left as None if undefined
initials: Optional[str]
avatar_url: Optional[str]
color: Optional[str]

# Jupyter Server permissions
# as a dict of permitted {"resource": ["actions"]}
permissions: Dict[str, List[str]]


class IdentityProvider(LoggingConfigurable):
"""
Interface for providing identity
Two principle methods:
- :meth:`~.IdentityProvider.get_user` returns a user object.
For successful authentication,
this may return anything truthy.
- :meth:`~.IdentityProvider.user_model` returns a standard identity model dictionary,
for use in the /me API.
This should accept whatever is returned from get_user()
and return a dictionary matching the structure of
:class:`~.IdentityModel`.
.. versionadded:: 2.0
"""

def get_user(self, handler: RequestHandler) -> Any:
"""Get the authenticated user for a request
User may be anything truthy, but must be understood by user_model method.
Return None if the request is not authenticated.
When in doubt, use a standard identity model.
"""

if handler.login_handler is None:
return {
"username": "anonymous",
}

# The default: call LoginHandler.get_user for backward-compatibility
# TODO: move default implementation to this class,
# deprecate `LoginHandler.get_user`
user = handler.login_handler.get_user(handler)
return user

def user_model(self, user: Any) -> IdentityModel:
"""Construct standardized user model for the identity API
Casts objects returned by `.get_user` (generally str username or dict with 'username' or 'name')
To a complete IdentityModel dict.
`username` is required.
Any other missing fields will be filled out with defaults.
"""
user_model = {}
if isinstance(user, str):
user_model["username"] = user
return {
"username": user,
"name": None,
}
elif isinstance(user, dict):
user_model = {}
# username may be in 'username' field or 'name' (e.g. JupyterHub)
for username_key in ("username", "name"):
if username_key in user:
user_model["username"] = user[username_key]
break
for key, value in user.items():
if key in IdentityModel:
user_model[key] = user

# handle other types, e.g. custom objects. Subclasses must define this method
# in order to handler these.
if "username" not in user_model:
clsname = self.__class__.__name__
self.log.warning(
f"Unable to find username in current_user. {clsname}.user_model() must accept user objects as returned by {clsname}.get_user()."
)
self.log.debug("Unable to find username in current_user: %s", user)
user_model["username"] = "unknown"
user_model.setdefault("given_name", None)
# fill defaults
return self.fill_defaults(user_model)

def _get_user_model(self, user: Any) -> IdentityModel:
"""
Private method to always return a filled user model
This is how the user model should be accessed, in general.
"""
return self.fill_defaults(self.user_model(user))

def fill_defaults(self, identity: IdentityModel) -> IdentityModel:
"""Fill out default fields in the identity model
- Ensures all values are defined
- Fills out derivative values for name fields fields
- Fills out null values for optional fields
"""

# username is the only truly required field
if not identity.get("username"):
raise ValueError(f"identity.username must not be empty: {identity}")

# derive name fields from username -> name -> display name
if not identity.get("name"):
identity["name"] = identity["username"]
if not identity.get("display_name"):
identity["display_name"] = identity["name"]

# fields that should be defined, but use null if no information is provided
for key in ("avatar_url", "color", "initials"):
identity.setdefault(key, None)
return identity
8 changes: 5 additions & 3 deletions jupyter_server/base/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,7 @@ def clear_login_cookie(self):
self.force_clear_cookie(self.cookie_name)

def get_current_user(self):
if self.login_handler is None:
return "anonymous"
return self.login_handler.get_user(self)
return self.identity_provider.get_user(self)

def skip_check_origin(self):
"""Ask my login_handler if I should skip the origin_check
Expand Down Expand Up @@ -195,6 +193,10 @@ def login_available(self):
def authorizer(self):
return self.settings["authorizer"]

@property
def identity_provider(self):
return self.settings["identity_provider"]


class JupyterHandler(AuthenticatedHandler):
"""Jupyter-specific extensions to authenticated handling
Expand Down
53 changes: 40 additions & 13 deletions jupyter_server/serverapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
GatewayClient,
)
from jupyter_server.auth.authorizer import Authorizer, AllowAllAuthorizer
from jupyter_server.auth.identity import IdentityProvider

from jupyter_server.auth.login import LoginHandler
from jupyter_server.auth.logout import LogoutHandler
Expand Down Expand Up @@ -230,7 +231,9 @@ def __init__(
default_url,
settings_overrides,
jinja_env_options,
*,
authorizer=None,
identity_provider=None,
):
if authorizer is None:
warnings.warn(
Expand All @@ -239,7 +242,16 @@ def __init__(
RuntimeWarning,
stacklevel=2,
)
authorizer = AllowAllAuthorizer(jupyter_app)
authorizer = AllowAllAuthorizer(parent=jupyter_app)

if identity_provider is None:
warnings.warn(
"identity_provider unspecified. Using default IdentityProvider."
" Specify an identity_provider to avoid this message.",
RuntimeWarning,
stacklevel=2,
)
identity_provider = IdentityProvider(parent=jupyter_app)

settings = self.init_settings(
jupyter_app,
Expand All @@ -255,6 +267,7 @@ def __init__(
settings_overrides,
jinja_env_options,
authorizer=authorizer,
identity_provider=identity_provider,
)
handlers = self.init_handlers(default_services, settings)

Expand All @@ -274,7 +287,9 @@ def init_settings(
default_url,
settings_overrides,
jinja_env_options=None,
*,
authorizer=None,
identity_provider=None,
):

_template_path = settings_overrides.get(
Expand Down Expand Up @@ -360,6 +375,7 @@ def init_settings(
kernel_spec_manager=kernel_spec_manager,
config_manager=config_manager,
authorizer=authorizer,
identity_provider=identity_provider,
# handlers
extra_services=extra_services,
# Jupyter stuff
Expand Down Expand Up @@ -559,7 +575,7 @@ class JupyterServerStopApp(JupyterApp):
help="Port of the server to be killed. Default %s" % DEFAULT_JUPYTER_SERVER_PORT,
)

sock = Unicode(u"", config=True, help="UNIX socket of the server to be killed.")
sock = Unicode("", config=True, help="UNIX socket of the server to be killed.")

def parse_command_line(self, argv=None):
super(JupyterServerStopApp, self).parse_command_line(argv)
Expand Down Expand Up @@ -795,7 +811,9 @@ def _default_log_level(self):
@default("log_format")
def _default_log_format(self):
"""override default log format to include date & time"""
return u"%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s]%(end_color)s %(message)s"
return (
"%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s]%(end_color)s %(message)s"
)

# file to be opened in the Jupyter server
file_to_run = Unicode("", help="Open the named file when the application is launched.").tag(
Expand Down Expand Up @@ -880,12 +898,12 @@ def _default_ip(self):
@validate("ip")
def _validate_ip(self, proposal):
value = proposal["value"]
if value == u"*":
value = u""
if value == "*":
value = ""
return value

custom_display_url = Unicode(
u"",
"",
config=True,
help=_i18n(
"""Override URL shown to users.
Expand Down Expand Up @@ -928,7 +946,7 @@ def port_default(self):
def port_retries_default(self):
return int(os.getenv(self.port_retries_env, self.port_retries_default_value))

sock = Unicode(u"", config=True, help="The UNIX socket the Jupyter server will listen on.")
sock = Unicode("", config=True, help="The UNIX socket the Jupyter server will listen on.")

sock_mode = Unicode(
"0600",
Expand Down Expand Up @@ -959,19 +977,19 @@ def _validate_sock_mode(self, proposal):
return value

certfile = Unicode(
u"",
"",
config=True,
help=_i18n("""The full path to an SSL/TLS certificate file."""),
)

keyfile = Unicode(
u"",
"",
config=True,
help=_i18n("""The full path to a private key file for usage with SSL/TLS."""),
)

client_ca = Unicode(
u"",
"",
config=True,
help=_i18n(
"""The full path to a certificate authority certificate for SSL/TLS client authentication."""
Expand Down Expand Up @@ -1053,7 +1071,7 @@ def _token_default(self):
if self.password:
# no token if password is enabled
self._token_generated = False
return u""
return ""
else:
self._token_generated = True
return binascii.hexlify(os.urandom(24)).decode("ascii")
Expand Down Expand Up @@ -1116,7 +1134,7 @@ def _token_changed(self, change):
self._token_generated = False

password = Unicode(
u"",
"",
config=True,
help="""Hashed password to use for web authentication.
Expand Down Expand Up @@ -1261,7 +1279,7 @@ def _default_allow_remote(self):
)

browser = Unicode(
u"",
"",
config=True,
help="""Specify what command to use to invoke a web
browser when starting the server. If not specified, the
Expand Down Expand Up @@ -1513,6 +1531,13 @@ def _observe_contents_manager_class(self, change):
help=_i18n("The authorizer class to use."),
)

identity_provider_class = Type(
default_value=IdentityProvider,
klass=IdentityProvider,
config=True,
help=_i18n("The identity provider class to use."),
)

trust_xheaders = Bool(
False,
config=True,
Expand Down Expand Up @@ -1838,6 +1863,7 @@ def init_configurables(self):
log=self.log,
)
self.authorizer = self.authorizer_class(parent=self, log=self.log)
self.identity_provider = self.identity_provider_class(parent=self, log=self.log)

def init_logging(self):
# This prevents double log messages because tornado use a root logger that
Expand Down Expand Up @@ -1925,6 +1951,7 @@ def init_webapp(self):
self.tornado_settings,
self.jinja_environment_options,
authorizer=self.authorizer,
identity_provider=self.identity_provider,
)
if self.certfile:
self.ssl_options["certfile"] = self.certfile
Expand Down
Loading

0 comments on commit 2f47b2e

Please sign in to comment.