Skip to content

fix(oidc): allow empty issuer to skip discovery for split-horizon#281

Open
devantler wants to merge 7 commits into
crossplane-contrib:pre-releasefrom
devantler:fix/oidc-empty-issuer-split-horizon
Open

fix(oidc): allow empty issuer to skip discovery for split-horizon#281
devantler wants to merge 7 commits into
crossplane-contrib:pre-releasefrom
devantler:fix/oidc-empty-issuer-split-horizon

Conversation

@devantler

@devantler devantler commented Jun 27, 2026

Copy link
Copy Markdown

Fixes #280

Problem

Split-horizon OIDC — a browser-facing authorizationURL plus in-cluster tokenURL/userInfoURL for the server-side code→token exchange and userinfo lookup — can't be configured. The blocker is a hardcoded http://localhost:8080/realms/crossview issuer default in two places, which forces a non-empty issuer and makes the server run OIDC discovery (whose discovered endpoints then override the explicit ones):

  1. Helm charttemplates/configmap.yaml rendered OIDC_ISSUER with | default "http://localhost:8080/realms/crossview", so an unset issuer could never render an empty OIDC_ISSUER.
  2. Go serverlib/sso_config.go also re-defaults an empty OIDC_ISSUER back to the same localhost URL via firstNonEmpty(…, "http://localhost:8080/realms/crossview").

The issue framed "option 1" as a chart-only change, but the chart default alone isn't sufficient: the server applies the same localhost fallback, so it would re-populate the issuer and run discovery regardless of what the chart renders. Both defaults have to go for the empty-issuer ("skip discovery") path to be reachable — this PR removes both.

With a non-empty issuer, services/sso_service.go runs discovery and the discovered authorization_endpoint/token_endpoint/userinfo_endpoint take precedence over the explicit overrides, so the in-cluster token/userinfo endpoints get bypassed (the wall #121 hit).

Fix (option 1, end-to-end)

Drop the hardcoded localhost issuer default so an unset/empty issuer stays empty. The server already supports this path — when Issuer == "" it skips discovery and uses the explicit authorizationURL/tokenURL/userInfoURL verbatim — this just makes that path reachable from config.

File Change
helm/crossview/templates/configmap.yaml OIDC_ISSUER now defaults to ""
crossview-go-server/lib/sso_config.go remove the localhost fallback from issuer resolution
config/loader.js mirror the same empty-issuer default in the JS config loader (kept in sync with the Go server)
helm/crossview/values.yaml, docs/SSO_SETUP.md document discovery-vs-explicit precedence + the split-horizon pattern
helm/crossview/tests/configmap_test.yaml new cases: empty issuer → empty OIDC_ISSUER; explicit endpoints pass through
…/sso/sso_test_helpers.go update the config assertion to expect an empty default

Configuring split-horizon after this change:

config:
  sso:
    oidc:
      enabled: true
      issuer: ""                                                   # empty -> discovery skipped
      authorizationURL: https://idp.example.com/authorize          # browser-reachable
      tokenURL: http://idp.idp.svc.cluster.local:5556/token        # in-cluster
      userInfoURL: http://idp.idp.svc.cluster.local:5556/userinfo  # in-cluster

A non-empty issuer behaves exactly as before (discovery runs).

Behavior change & the old default

The default OIDC_ISSUER is now empty instead of http://localhost:8080/realms/crossview. Enabling OIDC without an issuer and without explicit endpoints now fails fast with OIDC authorization URL not configured instead of silently probing localhost.

The old localhost default existed for the local Keycloak dev/demo, and that use case is unchanged: it's already set explicitly in the example configs (config/examples/config.yaml.example, config-session-sso.yaml.example) that the demo copies into the docker-compose-mounted config/config.yaml. Every documented OIDC-enable flow (docs/CONFIGURATION.md, docs/SSO_SETUP.md, the examples) sets issuer explicitly, so none relied on the removed fallback. The localhost default now lives only where it belongs — the example/demo configs — instead of being baked into production config resolution (where it caused this bug).

CI fix included (pre-existing, unrelated to the OIDC change)

The Helm Chart Tests job was failing on this PR before it ran a single chart assertion: .github/workflows/helm-test.yml installed the deprecated quintush/helm-unittest fork, which no longer ships its untt binary, so the masked helm plugin install … || true left helm unittest erroring with fork/exec …/untt: no such file or directory. This PR touches helm/** and was blocked by it, so it also switches the workflow to the maintained helm-unittest/helm-unittest plugin pinned to v1.0.1 (an unpinned install checks out main HEAD, whose plugin.yaml uses a platformHooks field Helm 3.13 can't parse) and drops || true so a failed install fails loudly. Verified on the runner's Helm 3.13.0: 23 tests pass. Happy to split this into its own PR if you'd prefer.

Validation

  • helm lint ✓; helm template ✓ — default renders OIDC_ISSUER: "", split-horizon renders the explicit URLs, a set issuer renders through.
  • helm unittest . ✓ — 5 suites, 23 tests (verified on both Helm 4.2.2 and the CI's Helm 3.13.0).
  • go build ./... ✓, go vet ./... ✓, go test ./... ✓; node --check config/loader.js ✓; actionlint ✓.

🤖 Generated with Claude Code (opened on behalf of @devantler).

devantler and others added 4 commits June 27, 2026 22:47
Drop the hardcoded http://localhost:8080/realms/crossview issuer default in both the Helm chart (templates/configmap.yaml) and the Go server (lib/sso_config.go) so an unset issuer stays empty. An empty issuer makes the server skip OIDC discovery and use the explicit authorizationURL/tokenURL/userInfoURL verbatim, which is required for split-horizon setups (public authorize URL for the browser + in-cluster token/userinfo). A non-empty issuer still triggers discovery as before.

Fixes crossplane-contrib#280

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Nikolai Emil Damm <nikolaiemildamm@icloud.com>
…ency

firstNonEmpty(env, viper, "") matches the sibling empty-default fields (ClientSecret, AuthorizationURL, TokenURL, UserInfoURL); behaviour is identical. Tightened the guard comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Nikolai Emil Damm <nikolaiemildamm@icloud.com>
Keep config/loader.js's OIDC issuer default in sync with the Go server (lib/sso_config.go): an unset issuer stays empty so discovery is skipped. Behaviour-preserving — the loader's sso block is only consumed via getConfig('vite') today — but avoids leaving the same localhost default we removed elsewhere. See crossplane-contrib#280.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Nikolai Emil Damm <nikolaiemildamm@icloud.com>
The full split-horizon rationale lives in lib/sso_config.go (the consumed OIDC path); loader.js's sso block is only read via getConfig('vite'), so a one-line pointer is enough here. No code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Nikolai Emil Damm <nikolaiemildamm@icloud.com>
@devantler devantler marked this pull request as ready for review June 27, 2026 21:07
@devantler

Copy link
Copy Markdown
Author

@MoeidHeidari should be ready for review now :-)

devantler and others added 2 commits June 28, 2026 21:59
The deprecated quintush/helm-unittest fork no longer ships its `untt` binary, so `helm plugin install ... || true` silently failed and `helm unittest` errored with "fork/exec .../untt: no such file or directory". Switch to the maintained helm-unittest/helm-unittest plugin pinned to v1.0.1 — an unpinned install checks out main HEAD whose plugin.yaml uses a `platformHooks` field Helm 3.13.0 cannot parse — and drop `|| true` so a failed install fails the job loudly.

Verified locally on Helm 3.13.0: 5 suites / 23 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Nikolai Emil Damm <nikolaiemildamm@icloud.com>
Note in the workflow that v1.0.1 is the newest helm-unittest release compatible with the pinned Helm 3.13.0 (v1.1.1+ use a platformHooks plugin.yaml field 3.13 can't parse); bumping the plugin further requires bumping Helm — left to the maintainers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Nikolai Emil Damm <nikolaiemildamm@icloud.com>
@devantler

Copy link
Copy Markdown
Author

@MoeidHeidari I fixed the failing CI check caused by the use of the deprecated quintush/helm-unittest fork. https://github.com/helm-unittest/helm-unittest seems to be community chosen actively maintained alternative.

@MoeidHeidari

Copy link
Copy Markdown
Collaborator

thanks @devantler . Even though we usually don't accept AI-generated code, this PR seems fairly straightforward and unambiguous. We can accept it. But before moving forward, we need some adjustments

  • target the PR to the pre-release branch instead of main, since we collect the changes on pre-release and make them ready for a release candidate
  • Please remove the unnecessary comments, especially those mentioning GitHub PRs. Most lines should be self-explanatory. We don't need comments everywhere.

Remove the explanatory code comments (and crossplane-contrib#280 references) flagged in review; the split-horizon behaviour stays documented in docs/SSO_SETUP.md. Keep a one-line note on the helm-unittest v1.0.1 pin (non-obvious: v1.1.1+ need a newer Helm). values.yaml reverts to its original commented example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Nikolai Emil Damm <nikolaiemildamm@icloud.com>
@devantler devantler changed the base branch from main to pre-release June 29, 2026 06:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants