diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py index 5687f3a..b532e16 100644 --- a/config/jupyterhub/01-spawner.py +++ b/config/jupyterhub/01-spawner.py @@ -5,10 +5,11 @@ import json import logging import os +import shlex 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 @@ -20,6 +21,38 @@ log = logging.getLogger(__name__) +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) + + # --------------------------------------------------------------------------- # Storage # --------------------------------------------------------------------------- @@ -1046,7 +1079,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 @@ -1054,6 +1087,126 @@ 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")) + + +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 _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 = ( + '!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: + 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} + _configure_git_for_github_token(spawner) + + # --------------------------------------------------------------------------- # Pre-spawn hook orchestrator # --------------------------------------------------------------------------- @@ -1090,7 +1243,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. Enterprise CA bundle (non-fatal). Off by default; on only when the # cluster runs trust-manager and the deployer/operator enables it. Runs @@ -1171,6 +1329,15 @@ async def _pre_spawn_hook(spawner): except Exception: log.exception("pre-spawn: NSS wrapper setup FAILED for %s", username) + # 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: + log.exception("pre-spawn: external-auth setup FAILED for %s", username) + log.info("pre-spawn: hook complete for user %s", username) 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" 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/tests/unit/test_external_auth.py b/tests/unit/test_external_auth.py new file mode 100644 index 0000000..8327027 --- /dev/null +++ b/tests/unit/test_external_auth.py @@ -0,0 +1,257 @@ +"""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.lifecycle_hooks = None + 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" + 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_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 + 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): + 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 diff --git a/values.yaml b/values.yaml index 2ba3062..aec7ae2 100644 --- a/values.yaml +++ b/values.yaml @@ -215,6 +215,16 @@ sharedStorage: # of `/` is broken — see shared-pvc.yaml for details. mountOptions: [] +# 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: "" + providers: [] + envVarMap: {} + timeoutSeconds: 10 + # ============================================================================= # Nebi Integration # =============================================================================