From dd44a2e26cdb83a185aa8f162ba60540df7ec5dc Mon Sep 17 00:00:00 2001 From: Mark Kusper Date: Mon, 4 May 2026 16:56:40 +0000 Subject: [PATCH 1/8] Add external auth contract integration --- config/jupyterhub/01-spawner.py | 66 ++++++++++++++++++++++++++++++++- templates/hub-config.yaml | 2 +- values.yaml | 9 +++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py index 4295bc3..2ada1c5 100644 --- a/config/jupyterhub/01-spawner.py +++ b/config/jupyterhub/01-spawner.py @@ -8,7 +8,7 @@ import urllib.error import urllib.request from pathlib import Path -from urllib.parse import urlencode +from urllib.parse import quote, urlencode from urllib.request import Request, urlopen import escapism @@ -19,6 +19,12 @@ log = logging.getLogger(__name__) +external_auth_enabled = "__EXTERNAL_AUTH_ENABLED__".lower() == "true" +external_auth_broker_url = "__EXTERNAL_AUTH_BROKER_URL__".strip() +external_auth_providers = json.loads(r'''__EXTERNAL_AUTH_PROVIDERS_JSON__''') +external_auth_env_var_map = json.loads(r'''__EXTERNAL_AUTH_ENV_VAR_MAP_JSON__''') +external_auth_timeout_seconds = int("__EXTERNAL_AUTH_TIMEOUT_SECONDS__" or "10") + # --------------------------------------------------------------------------- # Storage @@ -946,7 +952,7 @@ async def _ensure_workspace_pvc(spawner): log.info("Created workspace PVC %s in namespace %s", pvc_name, namespace) except ApiException as exc: if exc.status == 409: - log.info("Workspace PVC %s already exists", pvc_name) + log.info("Workspace PVC %s already exists", pvc_name) else: log.error("Failed to create workspace PVC %s: %s", pvc_name, exc) raise @@ -954,6 +960,56 @@ async def _ensure_workspace_pvc(spawner): return pvc_name +# --------------------------------------------------------------------------- +# External auth pack integration +# --------------------------------------------------------------------------- + +def _external_auth_provider_token(broker_url, provider, bearer_token): + """Fetch one provider token from the external-auth broker.""" + req = Request( + f"{broker_url.rstrip('/')}/external-auth/{quote(provider)}/token", + headers={"Authorization": f"Bearer {bearer_token}", "Accept": "application/json"}, + method="GET", + ) + with urlopen(req, timeout=external_auth_timeout_seconds) as resp: + return json.loads(resp.read().decode("utf-8")) + + +async def _external_auth_pre_spawn_hook(spawner, auth_state): + """Deliver external-auth provider tokens from configured contract fields.""" + if not external_auth_enabled or not external_auth_broker_url: + return + access_token = (auth_state or {}).get("access_token") + if not access_token: + log.debug("external-auth: no access_token for %s; skipping", spawner.user.name) + return + + providers = external_auth_providers or list(external_auth_env_var_map) + for provider in providers: + try: + payload = await asyncio.to_thread( + _external_auth_provider_token, + external_auth_broker_url, + provider, + access_token, + ) + except urllib.error.HTTPError as exc: + log.debug("external-auth: token request failed provider=%s status=%s", provider, exc.code) + continue + except Exception as exc: + log.debug("external-auth: token request failed provider=%s error=%s", provider, exc) + continue + + token = str(payload.get("access_token", "")).strip() + if payload.get("status") != "token_valid" or not token: + continue + + env_var = str(external_auth_env_var_map.get(provider) or f"{provider.upper()}_TOKEN").strip() + spawner.environment = {**spawner.environment, env_var: token} + if provider == "github": + spawner.environment = {**spawner.environment, "GH_TOKEN": token} + + # --------------------------------------------------------------------------- # Pre-spawn hook orchestrator # --------------------------------------------------------------------------- @@ -1053,6 +1109,12 @@ async def _pre_spawn_hook(spawner): except Exception: log.exception("pre-spawn: NSS wrapper setup FAILED for %s", username) + # 5. External auth provider tokens (non-fatal) + try: + await _external_auth_pre_spawn_hook(spawner, auth_state) + except Exception: + log.exception("pre-spawn: external-auth setup FAILED for %s", username) + log.info("pre-spawn: hook complete for user %s", username) diff --git a/templates/hub-config.yaml b/templates/hub-config.yaml index 6ec8cbd..38618b0 100644 --- a/templates/hub-config.yaml +++ b/templates/hub-config.yaml @@ -74,7 +74,7 @@ data: 00-gateway-auth.py: | {{ $gatewayAuthPy | indent 4 }} 01-spawner.py: | -{{ .Files.Get "config/jupyterhub/01-spawner.py" | replace "__SINGLEUSER_CONFIG_CM__" (include "nebari-data-science-pack.singleuser-config" .) | indent 4 }} +{{ .Files.Get "config/jupyterhub/01-spawner.py" | replace "__SINGLEUSER_CONFIG_CM__" (include "nebari-data-science-pack.singleuser-config" .) | replace "__EXTERNAL_AUTH_ENABLED__" (toString .Values.externalAuth.enabled) | replace "__EXTERNAL_AUTH_BROKER_URL__" .Values.externalAuth.brokerURL | replace "__EXTERNAL_AUTH_PROVIDERS_JSON__" (toJson .Values.externalAuth.providers) | replace "__EXTERNAL_AUTH_ENV_VAR_MAP_JSON__" (toJson .Values.externalAuth.envVarMap) | replace "__EXTERNAL_AUTH_TIMEOUT_SECONDS__" (toString .Values.externalAuth.timeoutSeconds) | indent 4 }} 02-jhub-apps.py: | {{ .Files.Get "config/jupyterhub/02-jhub-apps.py" | indent 4 }} 03-nebi-envs.py: | diff --git a/values.yaml b/values.yaml index 705df46..8272b4a 100644 --- a/values.yaml +++ b/values.yaml @@ -215,6 +215,15 @@ sharedStorage: # of `/` is broken — see shared-pvc.yaml for details. mountOptions: [] +# External auth pack integration. Copy these values from the +# ExternalAuthIntegration contract rendered by nebari-external-auth-pack. +externalAuth: + enabled: false + brokerURL: "" + providers: [] + envVarMap: {} + timeoutSeconds: 10 + # ============================================================================= # Nebi Integration # ============================================================================= From 9af85fe909a4bf799ff103bf39121938e2b1f13c Mon Sep 17 00:00:00 2001 From: Mark Kusper Date: Mon, 4 May 2026 17:55:02 +0000 Subject: [PATCH 2/8] Handle full JupyterHub external URLs --- config/jupyterhub/02-jhub-apps.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/config/jupyterhub/02-jhub-apps.py b/config/jupyterhub/02-jhub-apps.py index 5a8c0c8..16b1056 100644 --- a/config/jupyterhub/02-jhub-apps.py +++ b/config/jupyterhub/02-jhub-apps.py @@ -2,6 +2,7 @@ # ruff: noqa: F821 - `c` is a magic global provided by JupyterHub import os +from urllib.parse import urlparse from kubespawner import KubeSpawner from jhub_apps import theme_template_paths, themes @@ -12,9 +13,19 @@ # bind_url must include the real external hostname so JupyterHub constructs # correct OAuth redirect URLs for internal services like jhub-apps. # See: nebari's 02-spawner.py for the same pattern. -domain = get_chart_config("external-url") -if domain: - c.JupyterHub.bind_url = f"https://{domain}" +external_url = (get_chart_config("external-url") or "").strip() +if external_url: + # Accept either bare hostnames (jupyter.example.com), proper URLs + # (https://jupyter.example.com), or common typo form (https//host). + if external_url.startswith("https//"): + external_url = external_url.replace("https//", "https://", 1) + elif external_url.startswith("http//"): + external_url = external_url.replace("http//", "http://", 1) + + parsed = urlparse(external_url if "://" in external_url else f"https://{external_url}") + host = parsed.netloc or parsed.path + scheme = parsed.scheme or "https" + c.JupyterHub.bind_url = f"{scheme}://{host}" else: c.JupyterHub.bind_url = "http://0.0.0.0:8000" c.JupyterHub.default_url = "/hub/home" From 6666f4cebbf2a8cb57900887dd476bfe93f16a8f Mon Sep 17 00:00:00 2001 From: Mark Kusper Date: Mon, 4 May 2026 18:04:05 +0000 Subject: [PATCH 3/8] Default external auth provider env map --- values.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/values.yaml b/values.yaml index 8272b4a..6c12efb 100644 --- a/values.yaml +++ b/values.yaml @@ -221,7 +221,9 @@ externalAuth: enabled: false brokerURL: "" providers: [] - envVarMap: {} + envVarMap: + github: GITHUB_TOKEN + testoidc: TESTOIDC_TOKEN timeoutSeconds: 10 # ============================================================================= From 56fc30c2fb8ec28ddae378b60517877a36a6627c Mon Sep 17 00:00:00 2001 From: Mark Kusper Date: Mon, 4 May 2026 18:12:39 +0000 Subject: [PATCH 4/8] Keep external auth defaults provider neutral --- config/jupyterhub/01-spawner.py | 36 ++++++++++++++++++++++++++++----- values.yaml | 4 +--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py index 2ada1c5..2441ffb 100644 --- a/config/jupyterhub/01-spawner.py +++ b/config/jupyterhub/01-spawner.py @@ -19,11 +19,37 @@ log = logging.getLogger(__name__) -external_auth_enabled = "__EXTERNAL_AUTH_ENABLED__".lower() == "true" -external_auth_broker_url = "__EXTERNAL_AUTH_BROKER_URL__".strip() -external_auth_providers = json.loads(r'''__EXTERNAL_AUTH_PROVIDERS_JSON__''') -external_auth_env_var_map = json.loads(r'''__EXTERNAL_AUTH_ENV_VAR_MAP_JSON__''') -external_auth_timeout_seconds = int("__EXTERNAL_AUTH_TIMEOUT_SECONDS__" or "10") + +def _external_auth_unrendered(value): + """Return true for Helm placeholders in unit-test/import contexts.""" + return str(value).startswith("__EXTERNAL_AUTH_") + + +def _external_auth_json(value, default): + if _external_auth_unrendered(value): + return default + return json.loads(value) + + +def _external_auth_int(value, default): + if _external_auth_unrendered(value) or not str(value).strip(): + return default + return int(value) + + +external_auth_enabled = ( + False + if _external_auth_unrendered("__EXTERNAL_AUTH_ENABLED__") + else "__EXTERNAL_AUTH_ENABLED__".lower() == "true" +) +external_auth_broker_url = ( + "" + if _external_auth_unrendered("__EXTERNAL_AUTH_BROKER_URL__") + else "__EXTERNAL_AUTH_BROKER_URL__".strip() +) +external_auth_providers = _external_auth_json(r'''__EXTERNAL_AUTH_PROVIDERS_JSON__''', []) +external_auth_env_var_map = _external_auth_json(r'''__EXTERNAL_AUTH_ENV_VAR_MAP_JSON__''', {}) +external_auth_timeout_seconds = _external_auth_int("__EXTERNAL_AUTH_TIMEOUT_SECONDS__", 10) # --------------------------------------------------------------------------- diff --git a/values.yaml b/values.yaml index 6c12efb..8272b4a 100644 --- a/values.yaml +++ b/values.yaml @@ -221,9 +221,7 @@ externalAuth: enabled: false brokerURL: "" providers: [] - envVarMap: - github: GITHUB_TOKEN - testoidc: TESTOIDC_TOKEN + envVarMap: {} timeoutSeconds: 10 # ============================================================================= From 6847a75d3d0b76d2c01e409edda0879ee81fe061 Mon Sep 17 00:00:00 2001 From: Mark Kusper Date: Thu, 18 Jun 2026 19:02:39 +0000 Subject: [PATCH 5/8] Test external auth token delivery --- tests/unit/test_external_auth.py | 180 +++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 tests/unit/test_external_auth.py diff --git a/tests/unit/test_external_auth.py b/tests/unit/test_external_auth.py new file mode 100644 index 0000000..97cabb5 --- /dev/null +++ b/tests/unit/test_external_auth.py @@ -0,0 +1,180 @@ +"""Tests for external-auth provider token delivery.""" + +from __future__ import annotations + +import asyncio +import shutil +import subprocess +import sys +import textwrap +import types + +import pytest + +from conftest import FakeConfig, load_config_module +from test_chart_derived import REPO_ROOT, _extract_configmap_key + + +class _Log: + def debug(self, *args, **kwargs): + pass + + def info(self, *args, **kwargs): + pass + + def exception(self, *args, **kwargs): + pass + + +class _User: + name = "alice@example.test" + + def __init__(self, auth_state): + self._auth_state = auth_state + + async def get_auth_state(self): + return self._auth_state + + +class _Spawner: + def __init__(self, auth_state): + self.environment = {} + self.log = _Log() + self.user = _User(auth_state) + + +def _load_spawner_module(): + fake = types.ModuleType("z2jh") + fake.get_config = lambda _key, default=None: default + prior = sys.modules.get("z2jh") + sys.modules["z2jh"] = fake + try: + return load_config_module("01-spawner.py", inject_c=FakeConfig()) + finally: + if prior is None: + sys.modules.pop("z2jh", None) + else: + sys.modules["z2jh"] = prior + + +def test_external_auth_defaults_are_disabled_when_spawner_is_unrendered(): + """Unit tests import raw config files, before Helm replaces placeholders.""" + mod = _load_spawner_module() + + assert mod.external_auth_enabled is False + assert mod.external_auth_broker_url == "" + assert mod.external_auth_providers == [] + assert mod.external_auth_env_var_map == {} + assert mod.external_auth_timeout_seconds == 10 + + +def test_external_auth_injects_github_token_and_gh_token(monkeypatch): + mod = _load_spawner_module() + mod.external_auth_enabled = True + mod.external_auth_broker_url = "http://external-auth.nebari-system.svc.cluster.local" + mod.external_auth_providers = ["github"] + mod.external_auth_env_var_map = {"github": "GITHUB_TOKEN"} + + calls = [] + + def fake_provider_token(broker_url, provider, bearer_token): + calls.append((broker_url, provider, bearer_token)) + return {"status": "token_valid", "access_token": "ghp_example"} + + monkeypatch.setattr(mod, "_external_auth_provider_token", fake_provider_token) + + spawner = _Spawner({"access_token": "keycloak-access-token"}) + asyncio.run(mod._external_auth_pre_spawn_hook(spawner, {"access_token": "keycloak-access-token"})) + + assert calls == [ + ( + "http://external-auth.nebari-system.svc.cluster.local", + "github", + "keycloak-access-token", + ) + ] + assert spawner.environment["GITHUB_TOKEN"] == "ghp_example" + assert spawner.environment["GH_TOKEN"] == "ghp_example" + + +def test_external_auth_skips_when_broker_has_no_valid_provider_token(monkeypatch): + mod = _load_spawner_module() + mod.external_auth_enabled = True + mod.external_auth_broker_url = "http://external-auth.nebari-system.svc.cluster.local" + mod.external_auth_providers = ["github"] + mod.external_auth_env_var_map = {"github": "GITHUB_TOKEN"} + + monkeypatch.setattr( + mod, + "_external_auth_provider_token", + lambda *_args: {"status": "not_linked"}, + ) + + spawner = _Spawner({"access_token": "keycloak-access-token"}) + asyncio.run(mod._external_auth_pre_spawn_hook(spawner, {"access_token": "keycloak-access-token"})) + + assert spawner.environment == {} + + +def test_external_auth_rendered_into_spawner_config(tmp_path): + helm = shutil.which("helm") + if helm is None: + pytest.skip("helm not on PATH") + + charts_dir = REPO_ROOT / "charts" + has_deps = charts_dir.exists() and any(charts_dir.glob("jupyterhub-*.tgz")) + if not has_deps: + subprocess.run( + [helm, "dependency", "update", str(REPO_ROOT)], + capture_output=True, + text=True, + check=True, + ) + + values = tmp_path / "values.yaml" + values.write_text( + textwrap.dedent( + """ + keycloak: + hostname: keycloak.example.com + externalAuth: + enabled: true + brokerURL: http://external-auth.nebari-system.svc.cluster.local + providers: + - github + envVarMap: + github: GITHUB_TOKEN + timeoutSeconds: 7 + """ + ) + ) + + proc = subprocess.run( + [ + helm, + "template", + "data-science-pack", + str(REPO_ROOT), + "-f", + str(values), + "--namespace", + "jupyterhub", + ], + capture_output=True, + text=True, + check=True, + ) + + spawner_config = _extract_configmap_key(proc.stdout, "01-spawner.py") + assert 'if _external_auth_unrendered("true")' in spawner_config + assert 'else "true".lower() == "true"' in spawner_config + assert ( + 'else "http://external-auth.nebari-system.svc.cluster.local".strip()' + in spawner_config + ) + assert 'external_auth_providers = _external_auth_json(r\'\'\'["github"]\'\'\', [])' in spawner_config + assert ( + 'external_auth_env_var_map = _external_auth_json(r\'\'\'{"github":"GITHUB_TOKEN"}\'\'\', {})' + in spawner_config + ) + assert 'external_auth_timeout_seconds = _external_auth_int("7", 10)' in spawner_config From 8717ceb82cde055aac0484d741c56059c7e593ee Mon Sep 17 00:00:00 2001 From: Mark Kusper Date: Thu, 18 Jun 2026 22:10:22 +0000 Subject: [PATCH 6/8] Configure git to use injected GitHub token --- config/jupyterhub/01-spawner.py | 43 ++++++++++++++++++++++++++++++++ tests/unit/test_external_auth.py | 32 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py index 2441ffb..58fc3f3 100644 --- a/config/jupyterhub/01-spawner.py +++ b/config/jupyterhub/01-spawner.py @@ -5,6 +5,7 @@ import json import logging import os +import shlex import urllib.error import urllib.request from pathlib import Path @@ -1001,6 +1002,47 @@ def _external_auth_provider_token(broker_url, provider, bearer_token): return json.loads(resp.read().decode("utf-8")) +def _append_post_start_commands(spawner, commands): + """Append shell commands to the pod postStart hook.""" + if not commands: + return + + existing = dict(getattr(spawner, "lifecycle_hooks", None) or {}) + post_start = existing.get("postStart", {}) + command = post_start.get("exec", {}).get("command") if isinstance(post_start, dict) else None + new_script = " && ".join(commands) + + if command and len(command) == 3 and command[:2] == ["/bin/sh", "-c"]: + new_script = f"{command[2]} && {new_script}" + elif command: + log.warning("postStart lifecycle hook has unsupported shape; replacing it") + + spawner.lifecycle_hooks = { + **existing, + "postStart": {"exec": {"command": ["/bin/sh", "-c", new_script]}}, + } + + +def _configure_git_for_github_token(spawner): + """Teach git to use the injected GITHUB_TOKEN for normal GitHub HTTPS URLs.""" + helper = ( + '!f() { test "$1" = get || exit 0; ' + 'while IFS= read -r line; do test -z "$line" && break; done; ' + 'test -n "${GITHUB_TOKEN:-}" || exit 0; ' + 'printf "username=x-access-token\\npassword=%s\\n" "$GITHUB_TOKEN"; }; f' + ) + _append_post_start_commands( + spawner, + [ + "if command -v git >/dev/null 2>&1 && [ -n \"${GITHUB_TOKEN:-}\" ]; then " + "git config --global credential.https://github.com.username x-access-token && " + f"git config --global credential.https://github.com.helper {shlex.quote(helper)} && " + "git config --global url.https://github.com/.insteadOf git@github.com:; " + "fi", + ], + ) + + async def _external_auth_pre_spawn_hook(spawner, auth_state): """Deliver external-auth provider tokens from configured contract fields.""" if not external_auth_enabled or not external_auth_broker_url: @@ -1034,6 +1076,7 @@ async def _external_auth_pre_spawn_hook(spawner, auth_state): spawner.environment = {**spawner.environment, env_var: token} if provider == "github": spawner.environment = {**spawner.environment, "GH_TOKEN": token} + _configure_git_for_github_token(spawner) # --------------------------------------------------------------------------- diff --git a/tests/unit/test_external_auth.py b/tests/unit/test_external_auth.py index 97cabb5..a7ad74e 100644 --- a/tests/unit/test_external_auth.py +++ b/tests/unit/test_external_auth.py @@ -39,6 +39,7 @@ async def get_auth_state(self): class _Spawner: def __init__(self, auth_state): self.environment = {} + self.lifecycle_hooks = None self.log = _Log() self.user = _User(auth_state) @@ -95,6 +96,37 @@ def fake_provider_token(broker_url, provider, bearer_token): ] assert spawner.environment["GITHUB_TOKEN"] == "ghp_example" assert spawner.environment["GH_TOKEN"] == "ghp_example" + cmd = spawner.lifecycle_hooks["postStart"]["exec"]["command"] + assert cmd[:2] == ["/bin/sh", "-c"] + assert "credential.https://github.com.username x-access-token" in cmd[2] + assert "credential.https://github.com.helper" in cmd[2] + assert "GITHUB_TOKEN" in cmd[2] + assert "ghp_example" not in cmd[2] + assert "url.https://github.com/.insteadOf git@github.com:" in cmd[2] + + +def test_external_auth_git_config_preserves_existing_poststart(monkeypatch): + mod = _load_spawner_module() + mod.external_auth_enabled = True + mod.external_auth_broker_url = "http://external-auth.nebari-system.svc.cluster.local" + mod.external_auth_providers = ["github"] + mod.external_auth_env_var_map = {"github": "GITHUB_TOKEN"} + + monkeypatch.setattr( + mod, + "_external_auth_provider_token", + lambda *_args: {"status": "token_valid", "access_token": "ghp_example"}, + ) + + spawner = _Spawner({"access_token": "keycloak-access-token"}) + spawner.lifecycle_hooks = { + "postStart": {"exec": {"command": ["/bin/sh", "-c", "echo existing"]}}, + } + asyncio.run(mod._external_auth_pre_spawn_hook(spawner, {"access_token": "keycloak-access-token"})) + + cmd = spawner.lifecycle_hooks["postStart"]["exec"]["command"][2] + assert cmd.startswith("echo existing && ") + assert "credential.https://github.com.helper" in cmd def test_external_auth_skips_when_broker_has_no_valid_provider_token(monkeypatch): From 857c7913f8b7ce5e33edc4eb2cf5e73ea000ce5e Mon Sep 17 00:00:00 2001 From: Mark Kusper Date: Fri, 19 Jun 2026 21:47:51 +0000 Subject: [PATCH 7/8] Clarify external auth integration is optional --- config/jupyterhub/01-spawner.py | 2 +- values.yaml | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py index 58fc3f3..398ac91 100644 --- a/config/jupyterhub/01-spawner.py +++ b/config/jupyterhub/01-spawner.py @@ -1178,7 +1178,7 @@ async def _pre_spawn_hook(spawner): except Exception: log.exception("pre-spawn: NSS wrapper setup FAILED for %s", username) - # 5. External auth provider tokens (non-fatal) + # 6. Optional external auth provider tokens (disabled unless configured) try: await _external_auth_pre_spawn_hook(spawner, auth_state) except Exception: diff --git a/values.yaml b/values.yaml index 8272b4a..812624b 100644 --- a/values.yaml +++ b/values.yaml @@ -215,8 +215,9 @@ sharedStorage: # of `/` is broken — see shared-pvc.yaml for details. mountOptions: [] -# External auth pack integration. Copy these values from the -# ExternalAuthIntegration contract rendered by nebari-external-auth-pack. +# Optional external auth pack integration. Leave enabled=false unless +# nebari-external-auth-pack is installed and has rendered an +# ExternalAuthIntegration contract for this data-science pack. externalAuth: enabled: false brokerURL: "" From fe6a5be2202d6789e8912c5ff9507289315544c1 Mon Sep 17 00:00:00 2001 From: Mark Kusper Date: Mon, 22 Jun 2026 23:05:11 +0000 Subject: [PATCH 8/8] Configure Git author identity for spawned notebooks --- config/jupyterhub/01-spawner.py | 40 ++++++++++++++++++++++++++-- tests/unit/test_external_auth.py | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py index 398ac91..c9a95ea 100644 --- a/config/jupyterhub/01-spawner.py +++ b/config/jupyterhub/01-spawner.py @@ -1023,6 +1023,34 @@ def _append_post_start_commands(spawner, commands): } +def _preferred_git_email(oauth_user, username): + """Return a usable Git author email from auth claims or username fallback.""" + email = str((oauth_user or {}).get("email") or "").strip() + if email: + return email + username = str(username or "").strip() + if "@" in username: + return username + return "" + + +def _configure_git_user_identity(spawner): + """Set default Git author identity without overwriting user config.""" + _append_post_start_commands( + spawner, + [ + "if command -v git >/dev/null 2>&1; then " + "if [ -n \"${PREFERRED_USERNAME:-}\" ] && " + "! git config --global --get user.name >/dev/null 2>&1; then " + "git config --global user.name \"$PREFERRED_USERNAME\"; fi; " + "if [ -n \"${PREFERRED_EMAIL:-}\" ] && " + "! git config --global --get user.email >/dev/null 2>&1; then " + "git config --global user.email \"$PREFERRED_EMAIL\"; fi; " + "fi", + ], + ) + + def _configure_git_for_github_token(spawner): """Teach git to use the injected GITHUB_TOKEN for normal GitHub HTTPS URLs.""" helper = ( @@ -1115,7 +1143,12 @@ async def _pre_spawn_hook(spawner): # downstream tooling read this env var. oauth_user = (auth_state or {}).get("oauth_user") or {} preferred_username = oauth_user.get("preferred_username") or username - spawner.environment = {**spawner.environment, "PREFERRED_USERNAME": preferred_username} + preferred_email = _preferred_git_email(oauth_user, username) + spawner.environment = { + **spawner.environment, + "PREFERRED_USERNAME": preferred_username, + "PREFERRED_EMAIL": preferred_email, + } # 1. Nebi auto-auth (non-fatal) if _nebi_auth_configured: @@ -1178,7 +1211,10 @@ async def _pre_spawn_hook(spawner): except Exception: log.exception("pre-spawn: NSS wrapper setup FAILED for %s", username) - # 6. Optional external auth provider tokens (disabled unless configured) + # 6. Git commit identity for VSCode/JupyterLab authoring (non-fatal) + _configure_git_user_identity(spawner) + + # 7. Optional external auth provider tokens (disabled unless configured) try: await _external_auth_pre_spawn_hook(spawner, auth_state) except Exception: diff --git a/tests/unit/test_external_auth.py b/tests/unit/test_external_auth.py index a7ad74e..8327027 100644 --- a/tests/unit/test_external_auth.py +++ b/tests/unit/test_external_auth.py @@ -105,6 +105,51 @@ def fake_provider_token(broker_url, provider, bearer_token): assert "url.https://github.com/.insteadOf git@github.com:" in cmd[2] +def test_git_identity_uses_auth_email_and_preserves_existing_config(): + mod = _load_spawner_module() + spawner = _Spawner({"access_token": "keycloak-access-token"}) + spawner.environment = { + "PREFERRED_USERNAME": "Alice Example", + "PREFERRED_EMAIL": "alice@example.test", + } + + mod._configure_git_user_identity(spawner) + + cmd = spawner.lifecycle_hooks["postStart"]["exec"]["command"] + assert cmd[:2] == ["/bin/sh", "-c"] + assert "git config --global --get user.name" in cmd[2] + assert "git config --global user.name \"$PREFERRED_USERNAME\"" in cmd[2] + assert "git config --global --get user.email" in cmd[2] + assert "git config --global user.email \"$PREFERRED_EMAIL\"" in cmd[2] + assert "Alice Example" not in cmd[2] + assert "alice@example.test" not in cmd[2] + + +def test_git_identity_appends_to_existing_poststart(): + mod = _load_spawner_module() + spawner = _Spawner({"access_token": "keycloak-access-token"}) + spawner.environment = { + "PREFERRED_USERNAME": "Alice Example", + "PREFERRED_EMAIL": "alice@example.test", + } + spawner.lifecycle_hooks = { + "postStart": {"exec": {"command": ["/bin/sh", "-c", "echo nss-wrapper"]}}, + } + + mod._configure_git_user_identity(spawner) + + cmd = spawner.lifecycle_hooks["postStart"]["exec"]["command"][2] + assert cmd.startswith("echo nss-wrapper && ") + assert "git config --global user.name \"$PREFERRED_USERNAME\"" in cmd + + +def test_git_identity_email_falls_back_to_email_username(): + mod = _load_spawner_module() + + assert mod._preferred_git_email({}, "alice@example.test") == "alice@example.test" + assert mod._preferred_git_email({}, "alice") == "" + + def test_external_auth_git_config_preserves_existing_poststart(monkeypatch): mod = _load_spawner_module() mod.external_auth_enabled = True