Running Native + two OIDC/OAuth authenticators side-by-side with MultiAuthenticator — gotchas around username prefixes & home directories

Context

We run a JupyterHub deployment with three parallel login paths via oauthenticator’s MultiAuthenticator:

  1. NativeAuthenticator (/native) — local accounts (self-signup), backed by our own Postgres DB.
  2. GenericOAuthenticator against a Django-based OAuth provider (GeoNode / django-oauth-toolkit) (/geonodeoauth).
  3. GenericOAuthenticator against 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 with rshared propagation (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
5 Likes