feat(auth): support split-horizon OIDC for private-VPC clusters#161
feat(auth): support split-horizon OIDC for private-VPC clusters#161oren-openteams wants to merge 1 commit into
Conversation
Closes nebari-dev#158. ``KeyCloakConfig.build()`` and the module-level ``configure()`` in ``00-gateway-auth.py`` derive all four OIDC URLs (authorize / token / userinfo / end_session) from a single ``issuer``. On private-VPC clusters where the external Keycloak hostname resolves ONLY outside the cluster (browser side, via a private DNS zone or external resolver), the hub cannot resolve ``issuer`` from inside the cluster and the token + userinfo backchannel legs fail. Deployers work around this by overriding the entire authenticator via ``hub.extraConfig`` — a ~40-line ``GenericOAuthenticator`` block with hardcoded split URLs. This PR adds first-class split-horizon support so that workaround retires. Values surface — two new opt-in fields, both default empty (no behaviour change on existing deploys): - ``keycloak.backchannelURL`` (top-level) — base URL used for backchannel legs. The realm suffix (``/realms/<realm>``) is appended by the helper. Typical value: ``http://keycloak-keycloakx-http.keycloak.svc.cluster.local:8080``. - ``jupyterhub.custom.keycloak-backchannel-issuer-url`` (explicit override) — full backchannel issuer URL INCLUDING ``/realms/<realm>``. Provided for the rare case where the ``backchannelURL`` + realm pattern cannot express the desired shape. Chart-side wiring: - New helper ``nebari-data-science-pack.keycloakBackchannelIssuerURL`` in ``_helpers.tpl``. Order of precedence: explicit override, then ``backchannelURL`` + realm, then empty (= no split-horizon). - ``templates/hub-config.yaml`` emits the helper's output into ``_CHART_DERIVED`` under key ``keycloak-backchannel-issuer-url``, so ``get_chart_config`` returns the deployer's value when set and the chart-rendered default otherwise (mirrors ``keycloak-token-url``). Python-side wiring in ``config/jupyterhub/00-gateway-auth.py``: - ``KeyCloakConfig.build()`` gains an optional ``backchannel_issuer`` kwarg. When non-empty, ``token_url`` and ``userdata_url`` are derived from it; ``authorize_url`` and ``end_session_url`` keep using ``issuer``. The dataclass's ``issuer`` field itself is unchanged — what downstream validators check against ``iss`` claims. Empty string is treated as "no split-horizon" and is byte-identical to the prior single-issuer signature (backward-compat guard). - ``configure()`` gains a matching optional kwarg and threads it through. - The module-level call site reads ``keycloak-backchannel-issuer-url`` via ``get_chart_config`` (which already handles the deployer-override + chart-default precedence) and passes it to ``configure``. Missing / empty is fine — hub keeps the historical single-issuer behaviour. Runtime contract with Keycloak: For the ``iss`` claim in issued tokens to keep matching the external ``issuer`` (so JWKS validators still work), run Keycloak with ``KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true``. That flag makes KC dynamically choose the ``iss`` value based on the request's Host header, so a token minted via the backchannel URL still embeds the external hostname in ``iss``. This dependency is documented in the ``keycloak.backchannelURL`` docstring. Tests: 6 new assertions in ``tests/unit/test_keycloak_authenticator.py``: - ``KeyCloakConfig.build`` without backchannel — all URLs share issuer - ``KeyCloakConfig.build`` with backchannel — token + userinfo split, authorize + end_session unchanged, ``issuer`` field unchanged - Empty-string backchannel treated as no-split-horizon (footgun guard — Helm renders unset values as ``""`` and that must not produce ``//protocol/openid-connect/...`` URLs) - ``configure()`` wires backchannel through onto the authenticator class - ``configure()`` without the kwarg matches prior single-issuer shape - ``configure()`` with empty string treated as no-split-horizon Full ``test_keycloak_authenticator.py`` suite passes: 29/29.
|
Verified end-to-end on a private-VPC dev cluster (combined with #160 for the hub CA trust).
The one thing I hit that turned out to be out of scope for this PR is worth flagging: CI is green except one |
Closes #158.
Summary
KeyCloakConfig.build()and the module-levelconfigure()in00-gateway-auth.pyderive all four OIDC URLs (authorize / token / userinfo / end_session) from a singleissuer. On private-VPC clusters where the external Keycloak hostname resolves ONLY outside the cluster (browser side, via a private DNS zone or external resolver), the hub cannot resolveissuerfrom inside the cluster and the token + userinfo backchannel legs fail. Deployers work around this by overriding the entire authenticator viahub.extraConfig— a ~40-lineGenericOAuthenticatorblock with hardcoded split URLs.This PR adds first-class split-horizon support so that workaround retires.
Values surface — two new opt-in fields, both default empty
No behaviour change on existing deploys.
keycloak.backchannelURL(top-level) — base URL used for backchannel legs. The realm suffix (/realms/<realm>) is appended by the helper. Typical value:http://keycloak-keycloakx-http.keycloak.svc.cluster.local:8080.jupyterhub.custom.keycloak-backchannel-issuer-url(explicit override) — full backchannel issuer URL INCLUDING/realms/<realm>. Provided for the rare case where thebackchannelURL+ realm pattern cannot express the desired shape.Chart-side wiring
nebari-data-science-pack.keycloakBackchannelIssuerURLin_helpers.tpl. Order of precedence: explicit override →backchannelURL+ realm → empty (= no split-horizon). Emits without the/protocol/openid-connectsuffix so the Python side owns the URL shape.templates/hub-config.yamlemits the helper's output into_CHART_DERIVEDunder keykeycloak-backchannel-issuer-url, soget_chart_configreturns the deployer's value when set and the chart-rendered default otherwise (mirrors the existingkeycloak-token-urlpattern).Python-side wiring
config/jupyterhub/00-gateway-auth.py:KeyCloakConfig.build()gains an optionalbackchannel_issuerkwarg. When non-empty,token_urlanduserdata_urlare derived from it;authorize_urlandend_session_urlkeep usingissuer. The dataclass'sissuerfield itself is unchanged — that's what downstream validators check againstissclaims. Empty string is treated as "no split-horizon" and is byte-identical to the prior single-issuer signature (backward-compat guard).configure()gains a matching optional kwarg and threads it through.The module-level call site reads
keycloak-backchannel-issuer-urlviaget_chart_config(which already handles the deployer-override + chart-default precedence) and passes it toconfigure. Missing / empty is fine — the hub keeps the historical single-issuer behaviour.Runtime contract with Keycloak
For the
issclaim in issued tokens to keep matching the externalissuer(so JWKS validators still work), run Keycloak withKC_HOSTNAME_BACKCHANNEL_DYNAMIC=true. That flag makes KC dynamically choose theissvalue based on the request'sHostheader, so a token minted via the backchannel URL still embeds the external hostname iniss. This dependency is documented in thekeycloak.backchannelURLdocstring.Tests
6 new assertions in
tests/unit/test_keycloak_authenticator.py:test_kc_config_build_without_backchannel_derives_all_from_issuer— default (backward-compat) shapetest_kc_config_build_with_backchannel_splits_urls— the split-horizon contract; verifies which URLs use which basetest_kc_config_build_with_empty_backchannel_matches_default— empty-string backchannel is treated as no-split-horizon (footgun guard: Helm renders unset values as""and that must not produce//protocol/openid-connect/...URLs)test_configure_wires_backchannel_issuer_onto_authenticator— end-to-end throughconfigure()test_configure_without_backchannel_leaves_single_issuer_shape— backward-compat guardtest_configure_treats_empty_backchannel_string_as_no_split_horizon— same footgun guarded at the configure() layerFull
test_keycloak_authenticator.pysuite (23 pre-existing + 6 new):Related
Companion PR #160 adds the hub-side org-CA-bundle wiring — the two together let the chart-native
KeyCloakOAuthenticatorfully replace the ~70-linehub.extraConfigoverride that private-VPC deployments currently maintain out-of-band.