Context
We run a JupyterHub deployment with three parallel login paths via oauthenticator’s MultiAuthenticator:
NativeAuthenticator(/native) — local accounts (self-signup), backed by our own Postgres DB.GenericOAuthenticatoragainst a Django-based OAuth provider (GeoNode / django-oauth-toolkit) (/geonodeoauth).GenericOAuthenticatoragainst Keycloak (/gumaccount).
Getting this combination working smoothly took quite a bit of trial and error, so I wanted to write up the non-obvious parts in case they save someone else time.
Spawner: DockerSpawner (subclassed).
Gotcha #1: MultiAuthenticator prefixes usernames, which breaks everything downstream
MultiAuthenticator wraps each authenticator and prefixes the returned username with its url_prefix (e.g. gumaccount:jane.doe@example.com, native:jane.doe). That’s fine for uniqueness in the Hub’s user table, but it immediately breaks:
- Docker container/volume names (which need to be filesystem/Docker-safe)
NB_USER/ home directory paths- Any UID/GID lookup against the host’s
/etc/passwd
We solved this with a subclass that monkey-patches authenticate, check_allowed, and check_blocked_users on each wrapped authenticator after MultiAuthenticator.__init__ has run — stripping the prefix and (for OIDC/OAuth) the email domain from the returned name, then re-adding the prefix only when calling into the parent’s allow_all/blocked_users logic (which still expects the prefixed form internally).
Important: we deliberately did not patch normalize_username on the wrapped authenticators — that’s the method MultiAuthenticator’s own prefixing mechanism relies on, and patching it there breaks username_prefix handling upstream.
class ILSMultiAuthenticator(MultiAuthenticator):
"""
Strips the MultiAuthenticator url_prefix and email domain so the
JupyterHub username is clean (e.g. 'jane.doe' -> DB, routing, volumes).
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
import types
for auth in self._authenticators:
prefix = auth.username_prefix # e.g. 'gumaccount:' or 'native:'
orig_authenticate = auth.__class__.authenticate
orig_allowed = auth.__class__.check_allowed
orig_blocked = auth.__class__.check_blocked_users
def make_authenticate(p, orig):
async def authenticate(self, handler, data=None, **kwargs):
response = await orig(self, handler, data, **kwargs)
if response is None:
return None
name = response if isinstance(response, str) else response.get('name', '')
if name.startswith(p):
name = name[len(p):]
if '@' in name:
name = name.split('@')[0]
name = re.sub(r'[^a-zA-Z0-9._\-]', '_', name)[:64] or 'user'
if isinstance(response, str):
return name
response['name'] = name
return response
return authenticate
def make_check(p, orig):
def check(self, username, authentication=None):
# re-add prefix so the parent's startswith() check still passes
return orig(self, p + username, authentication)
return check
auth.authenticate = types.MethodType(make_authenticate(prefix, orig_authenticate), auth)
auth.check_allowed = types.MethodType(make_check(prefix, orig_allowed), auth)
auth.check_blocked_users = types.MethodType(make_check(prefix, orig_blocked), auth)
One side-effect to be aware of: things like c.JupyterHub.admin_users may still need both the prefixed and unprefixed form depending on when the check runs, e.g. {"jane.doe", "native:jane.doe"}.
Gotcha #2: NativeAuthenticator + SQLAlchemy 2.x
If you’re on newer SQLAlchemy, NativeAuthenticator.add_new_table() calls self.db.bind, which was removed in SQLAlchemy 2.0. We patched it to build a fresh create_engine() from the known DB URL instead:
def _patched_add_new_table(self):
from sqlalchemy import create_engine, inspect
from nativeauthenticator.orm import UserInfo
engine = create_engine(DB_URL)
try:
if not inspect(engine).has_table(UserInfo.__tablename__):
UserInfo.__table__.create(engine)
finally:
engine.dispose()
NativeAuthenticator.add_new_table = _patched_add_new_table
We also found that when running under MultiAuthenticator, NativeAuthenticator doesn’t always get a self.db session handed to it — so get_user() needs a guard that lazily creates one if missing.
Gotcha #3: Native users have a real host home directory, OIDC/OAuth users don’t
This is the one that probably needs the most thought if you’re adapting this setup. Native accounts on our Hub map 1:1 to actual Linux system users with a real /home/<user> (including autofs-mounted network shares). OIDC/OAuth users exist only in Keycloak/Django — there’s no corresponding host account or home directory.
So we can’t use a single volume strategy for everyone. Our DockerSpawner subclass overrides volume_binds to decide per spawn:
- If a real
/home/<username>directory exists on the host → bind-mount it withrsharedpropagation (so autofs sub-mounts triggered after the container starts still show up inside the container — this matters for CIFS/NFS shares mounted on demand). - Otherwise → fall back to a named Docker volume
jupyter-home-<username>(the safe default that “just works” for accounts with no host presence).
@property
def volume_binds(self):
binds = super().volume_binds
path_user = self._extract_path_user(self.user.name)
vol_key = f"jupyter-home-{path_user}"
host_home = f"/home/{path_user}"
if vol_key in binds and os.path.isdir(host_home):
bind_dest = binds.pop(vol_key)["bind"]
binds[host_home] = {"bind": bind_dest, "mode": "rw", "propagation": "rshared"}
return binds
The base config declares the named volume as the default for everyone:
c.DockerSpawner.volumes = {
'jupyter-home-{username}': {'bind': '/home/{username}', 'mode': 'rw'},
# ... shared bind mounts for per-user config templates, scripts, etc.
}
and this property upgrades it to a bind mount only when appropriate. {username} here is always the normalized name (see Gotcha #1) via an escaped_name override, so Docker resource names stay consistent across all three auth backends.
We also derive NB_UID/NB_GID from the host’s /etc/passwd//etc/group (bind-mounted read-only into the container) for native users, so bind-mounted home directories get correct ownership; OIDC users fall back to a fixed UID/GID since there’s no host account to look up.
Gotcha #4: Change-password and logout need to branch per auth type
There’s no single “change password” or “logout” URL that makes sense across native + 2× OIDC. We added custom handlers that inspect auth_state (enable_auth_state = True is required) to detect whether the session came from an OAuth flow (presence of access_token/id_token/etc.) or native login, and redirect accordingly:
- Native →
/hub/native/change-password - OIDC/OAuth → the provider’s own account-management / logout URL (e.g. Keycloak’s
.../account/and.../protocol/openid-connect/logout?redirect_uri=...)
class ChangePasswordRedirectHandler(BaseHandler):
@web.authenticated
async def get(self):
user = self.current_user
auth_state = await user.get_auth_state()
if auth_state and any(k in auth_state for k in ("access_token", "id_token", "token", "refresh_token")):
self.redirect(OIDC_ACCOUNT_URL)
return
self.redirect(url_path_join(self.hub.base_url, "native", "change-password"))
Full authenticator config (sanitized)
c.JupyterHub.authenticator_class = ILSMultiAuthenticator
c.MultiAuthenticator.authenticators = [
{
"authenticator_class": NativeAuthenticator,
"url_prefix": "/native",
"config": {
"login_service": "native",
"open_signup": True,
"allow_all": True,
},
},
{
"authenticator_class": GenericOAuthenticator,
"url_prefix": "/djangooauth",
"config": {
"login_service": "django-oauth-provider",
"client_id": "<CLIENT_ID>",
"client_secret": "<CLIENT_SECRET>",
"oauth_callback_url": "https://<hub-host>/hub/oauth_callback",
"authorize_url": "https://<django-oauth-host>/o/authorize",
"token_url": "https://<django-oauth-host>/o/token",
"userdata_url": "https://<django-oauth-host>/o/userinfo",
"scope": "openid",
"username_claim": "email",
"allow_all": True,
},
},
{
"authenticator_class": GenericOAuthenticator,
"url_prefix": "/keycloak",
"config": {
"login_service": "keycloak",
"client_id": "<CLIENT_ID>",
"client_secret": "<CLIENT_SECRET>",
"oauth_callback_url": "https://<hub-host>/hub/keycloak/oauth_callback",
"authorize_url": "https://<keycloak-host>/realms/<realm>/protocol/openid-connect/auth",
"token_url": "https://<keycloak-host>/realms/<realm>/protocol/openid-connect/token",
"userdata_url": "https://<keycloak-host>/realms/<realm>/protocol/openid-connect/userinfo",
"scope": "openid email profile",
"username_claim": "email",
"allow_all": True,
"account_url": "https://<keycloak-host>/realms/<realm>/account/",
"logout_url": "https://<keycloak-host>/realms/<realm>/protocol/openid-connect/logout?redirect_uri=https://<hub-host>/hub/login",
},
},
]
c.Authenticator.enable_auth_state = True