Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,57 @@ ui-dev: ui-install ## Start Go backend + Vite dev server against the UI dev clus
--auth-mode=token \
--dev-vite-url=http://localhost:5173"

# The remote Zitadel our production deployments authenticate against
ZITADEL_ISSUER ?= https://zitadel.opendefense.cloud
ZITADEL_CLIENT_ID ?= 387085129840888657
Comment thread
rebEllieous marked this conversation as resolved.
Outdated
UI_DEV_PORT ?= 8090
Comment thread
rebEllieous marked this conversation as resolved.
Outdated
ZITADEL_REDIRECT_URL ?= http://localhost:$(UI_DEV_PORT)/api/auth/callback
# Kubernetes username to grant cluster-admin in the dev cluster — your Zitadel
# email. Without it you can log in but every API call is denied.
ZITADEL_USER ?=
# How the identity reaches Kubernetes. "impersonate" needs no cluster config:
# the BFF authenticates with the admin kubeconfig and impersonates you.
# "token" is what production uses — the API server validates the id_token
# itself, so the issuer has to be registered in its authentication config,
# which ui-dev-zitadel does for you.
ZITADEL_AUTH_MODE ?= impersonate

.PHONY: ui-dev-zitadel
ui-dev-zitadel: ui-install ## Start Go backend + Vite dev server against the remote Zitadel (PKCE, no client secret)
@case "$$($(KIND) get clusters 2>/dev/null)" in \
*"$(KIND_CLUSTER_UI_DEV)"*) ;; \
*) echo "UI dev cluster not found. Creating it..."; $(MAKE) ui-dev-cluster ;; \
esac
@mkdir -p $(UI_DEV_WORK_DIR)
@$(KIND) get kubeconfig --name $(KIND_CLUSTER_UI_DEV) > $(UI_DEV_WORK_DIR)/kubeconfig
@if [ "$(ZITADEL_AUTH_MODE)" = "token" ]; then \
KIND_CLUSTER=$(KIND_CLUSTER_UI_DEV) KUBECTL=$(KUBECTL) \
KUBECONFIG="$(UI_DEV_WORK_DIR)/kubeconfig" WORK_DIR="$(UI_DEV_WORK_DIR)" \
ZITADEL_ISSUER=$(ZITADEL_ISSUER) ZITADEL_CLIENT_ID=$(ZITADEL_CLIENT_ID) \
$(HACK_DIR)/trust-zitadel-issuer.sh; \
fi
@if [ -n "$(ZITADEL_USER)" ]; then \
echo "Granting cluster-admin to $(ZITADEL_USER)..."; \
KUBECONFIG=$(UI_DEV_WORK_DIR)/kubeconfig $(KUBECTL) create clusterrolebinding solar-ui-zitadel-admin \
--clusterrole=cluster-admin --user='$(ZITADEL_USER)' --dry-run=client -o yaml \
| KUBECONFIG=$(UI_DEV_WORK_DIR)/kubeconfig $(KUBECTL) apply -f -; \
else \
echo "WARNING: ZITADEL_USER is unset — you will log in but see 403s."; \
echo " Re-run with: make ui-dev-zitadel ZITADEL_USER=you@example.com"; \
fi
@echo "Open http://localhost:$(UI_DEV_PORT) in your browser."
@echo ""
cd web && $(PNPM) exec concurrently --kill-others --names "vite,bff" --prefix-colors "cyan,yellow" \
"$(PNPM) dev --port 5173" \
"sleep 2 && cd $(BUILD_PATH) && $(GO) run ./cmd/solar-ui \
--listen=0.0.0.0:$(UI_DEV_PORT) \
--kubeconfig=$(UI_DEV_WORK_DIR)/kubeconfig \
--oidc-issuer=$(ZITADEL_ISSUER) \
--oidc-client-id=$(ZITADEL_CLIENT_ID) \
--oidc-redirect-url=$(ZITADEL_REDIRECT_URL) \
--auth-mode=$(ZITADEL_AUTH_MODE) \
--dev-vite-url=http://localhost:5173"

.PHONY: ui-e2e-cluster
ui-e2e-cluster: ocm-transfer-demo ## Create a Kind cluster with Dex + SolAr for UI e2e testing
WORK_DIR=$(UI_E2E_WORK_DIR) $(HACK_DIR)/generate-dex-certs.sh
Expand Down
25 changes: 25 additions & 0 deletions docs/developer-guide/frontend-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,31 @@ The UI uses OIDC against the in-cluster Dex. After opening `http://localhost:809

All passwords are the literal string `password`. Cluster RBAC bindings live in `test/fixtures/e2e/dex/dex-rbac.yaml` (inlined from `docs/developer-guide/manifests/`).

## Testing against the remote Zitadel

Production authenticates against ZITADEL, not Dex. To point the dev UI at it:
Comment thread
rebEllieous marked this conversation as resolved.
Outdated

```bash
make ui-dev-zitadel ZITADEL_USER=you@example.com
```
Comment thread
rebEllieous marked this conversation as resolved.

`ZITADEL_USER` is your Zitadel email. It becomes your Kubernetes username, and the target grants it `cluster-admin` in the dev cluster. Issuer and client ID default to the real ones (see Makefile); override `ZITADEL_ISSUER`, `ZITADEL_CLIENT_ID` or `ZITADEL_REDIRECT_URL` to point elsewhere.

Three things differ from the Dex flow:

- **Public client with PKCE.** The BFF holds no client secret; it authenticates the code exchange with an S256 challenge. Zitadel registers us as a _native app_, which is what permits the loopback redirect URI. PKCE is sent on every login regardless of IdP, so there is nothing to switch.
- **`--auth-mode=impersonate` by default.** The BFF authenticates with the admin kubeconfig and impersonates you, so the cluster needs no OIDC configuration at all. Production uses token mode; to run the dev cluster the same way:

```bash
make ui-dev-zitadel ZITADEL_USER=you@example.com ZITADEL_AUTH_MODE=token
```

That runs `hack/trust-zitadel-issuer.sh`, which registers the issuer in the API server's authentication config (audience = client ID, `email` claim as the username) and waits for the hot reload. The Kind node needs egress and DNS to fetch the issuer's JWKS. The Dex issuer stays registered alongside it, so `make ui-dev` keeps working.

Comment thread
rebEllieous marked this conversation as resolved.
- **No groups.** Zitadel emits no `groups` claim, and the BFF reads only that claim, so sessions come back with an empty group list. Nothing depends on it: cluster RBAC binds on the `email` claim, and the UI decides what to show by asking Kubernetes (`SelfSubjectAccessReview` / `SelfSubjectRulesReview`) rather than by inspecting groups. The application does need `idTokenUserinfoAssertion` enabled so `email` and `name` are in the ID token at all — the BFF never calls the userinfo endpoint.

Zitadel ignores the port of a loopback redirect URI, so if `:8090` is busy you can run on another port as long as `ZITADEL_REDIRECT_URL` and the browser agree.

## Namespace selector

The sidebar's namespace dropdown is the global scope for every list page (Targets, Releases, Components, Profiles, …). It has two modes:
Expand Down
26 changes: 26 additions & 0 deletions docs/operator-manual/installation/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,29 @@ To install SolAr, navigate to the [releases page](https://github.com/opendefense
#### Helm

See [Helm installation](./helm.md) for more information.

### Web UI (OIDC)

The UI is off by default because it needs an OIDC issuer. The backend is a **public client**: it holds no client secret and authenticates the authorization-code exchange with PKCE (S256) instead. Set `ui.oidc.existingSecret` only if your IdP issues a confidential client.

Installing against the Open Defense Cloud Zitadel:

```yaml
ui:
enabled: true
oidc:
issuer: https://zitadel.opendefense.cloud
clientID: '387085129840888657'
# The externally reachable /api/auth/callback of this UI. SolAr ships no
# Ingress, so access is via `kubectl port-forward` and the browser sees
# loopback. Registered as a native app in Zitadel, which permits loopback
# redirect URIs without putting the application into development mode.
redirectURL: http://localhost:8090/api/auth/callback
existingSecret: '' # public client — PKCE, no secret
```
Comment thread
cbrgm marked this conversation as resolved.

```bash
Comment thread
rebEllieous marked this conversation as resolved.
kubectl port-forward -n solar-system svc/solar-ui 8090:8090
```

Kubernetes RBAC binds to the `email` claim, so the API server must be configured to trust the same issuer with the client ID as audience. See [Roles](../../developer-guide/roles.md) for the persona bindings.
91 changes: 91 additions & 0 deletions hack/trust-zitadel-issuer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
#
# Registers the remote Zitadel as a JWT issuer in the UI dev cluster's API
# server, so it accepts SolAr UI id_tokens directly (--auth-mode=token).
# Only needed for token mode
Comment thread
rebEllieous marked this conversation as resolved.
Outdated

set -euo pipefail

KUBECTL="${KUBECTL:-kubectl}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
WORK_DIR="${WORK_DIR:-$PROJECT_DIR/tmp/ui-dev}"

AUTH_CONFIG="$WORK_DIR/dex-auth-config.yaml"
AUTH_CONFIG_BASE="$WORK_DIR/dex-auth-config.base.yaml"

ISSUER="${ZITADEL_ISSUER:?ZITADEL_ISSUER is required}"
CLIENT_ID="${ZITADEL_CLIENT_ID:?ZITADEL_CLIENT_ID is required}"

# Guard against reconfiguring authentication on a non-local cluster.
CURRENT_CONTEXT="$($KUBECTL config current-context 2>/dev/null || true)"
EXPECTED_CONTEXT="${KIND_CLUSTER:+kind-${KIND_CLUSTER}}"
if [[ "${ALLOW_NON_LOCAL_CLUSTER:-false}" != "true" ]]; then
if [[ -n "$EXPECTED_CONTEXT" && "$CURRENT_CONTEXT" != "$EXPECTED_CONTEXT" ]]; then
echo "Refusing to run against context '${CURRENT_CONTEXT:-<none>}' (expected '$EXPECTED_CONTEXT')." >&2
exit 1
fi
if [[ -z "$EXPECTED_CONTEXT" && ! "$CURRENT_CONTEXT" =~ ^kind- ]]; then
echo "Refusing to reconfigure authentication on non-kind context: ${CURRENT_CONTEXT:-<none>}" >&2
Comment thread
rebEllieous marked this conversation as resolved.
exit 1
fi
fi

[[ -f "$AUTH_CONFIG" ]] || { echo "Missing $AUTH_CONFIG — run 'make ui-dev-cluster' first." >&2; exit 1; }

# Rebuilt from a Dex-only base, so a changed issuer or client ID can't leave a
# stale entry behind. The base must not already contain our block.
if [[ ! -f "$AUTH_CONFIG_BASE" ]]; then
if [[ "$(grep -c '^ - issuer:' "$AUTH_CONFIG")" -ne 1 ]]; then
echo "$AUTH_CONFIG has extra issuers and no $AUTH_CONFIG_BASE to rebuild" >&2
echo "from. Regenerate it with hack/generate-dex-certs.sh." >&2
Comment thread
rebEllieous marked this conversation as resolved.
Outdated
exit 1
fi
cp "$AUTH_CONFIG" "$AUTH_CONFIG_BASE"
fi

# No groups mapping: Zitadel's roles claim is a map, which the API server can't
# turn into group names. RBAC binds on the username.
DESIRED="$(printf '%s\n' "$(cat "$AUTH_CONFIG_BASE")" " - issuer:
Comment thread
rebEllieous marked this conversation as resolved.
Outdated
url: $ISSUER
audiences:
- \"$CLIENT_ID\"
claimMappings:
username:
claim: email
prefix: \"\"")"

if [[ "$(cat "$AUTH_CONFIG")" == "$DESIRED" ]]; then
echo "$ISSUER already registered in $AUTH_CONFIG."
exit 0
fi

echo "Registering $ISSUER in $AUTH_CONFIG..."
# Taken before the write: a reload logged earlier isn't evidence about ours.
SINCE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '%s\n' "$DESIRED" > "$AUTH_CONFIG"

echo "Waiting for the API server to reload it..."
for _ in $(seq 1 45); do
sleep 2
LOG="$($KUBECTL logs -n kube-system -l component=kube-apiserver --since-time="$SINCE" 2>/dev/null || true)"

ERRORS="$(grep -E "failed to (load|validate|update) authentication config" <<<"$LOG" || true)"
MINE="$(grep -F "\"$ISSUER\"" <<<"$ERRORS" || true)"
UNATTRIBUTED="$(grep -vF 'issuer \"' <<<"$ERRORS" || true)"
Comment thread
rebEllieous marked this conversation as resolved.
Outdated
if [[ -n "$MINE$UNATTRIBUTED" ]]; then
echo "The API server rejected the authentication config:" >&2
printf '%s\n' "$MINE$UNATTRIBUTED" | tail -1 >&2
exit 1
fi

if grep -q "reloaded authentication config" <<<"$LOG"; then
echo "API server reloaded."
exit 0
fi
done

# Changed on disk but identical to what's loaded — e.g. reverting a config the
# API server refused. Nothing to reload.
echo "No API-server reload was observed after changing $AUTH_CONFIG." >&2
exit 1
74 changes: 64 additions & 10 deletions pkg/ui/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"time"
Expand All @@ -31,9 +32,18 @@ import (
. "github.com/onsi/gomega"
)

// tokenForm captures the form the fake IdP's token endpoint last received, so
// tests can assert on what the client sent (e.g. the PKCE code_verifier).
var tokenForm url.Values

// fakeIDP is a minimal OIDC provider that issues a verifiable id_token, so the
// full callback path (token exchange → JWT verification → claims) can be tested.
func fakeIDP(clientID string) *httptest.Server {
// userClaims is a JSON fragment (no braces, no leading comma) appended to the
// registered claims. It is what distinguishes a Dex-shaped token from a
// Zitadel-shaped one.
func fakeIDP(clientID, userClaims string) *httptest.Server {
tokenForm = nil

key, err := rsa.GenerateKey(rand.Reader, 2048)
Expect(err).NotTo(HaveOccurred())
b64 := base64.RawURLEncoding.EncodeToString
Expand All @@ -56,12 +66,15 @@ func fakeIDP(clientID string) *httptest.Server {
"e": b64(big.NewInt(int64(key.PublicKey.E)).Bytes()),
}}})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
tokenForm = r.Form

now := time.Now().Unix()
header := b64([]byte(`{"alg":"RS256","typ":"JWT","kid":"test"}`))
payload := b64(fmt.Appendf(nil,
`{"iss":%q,"aud":%q,"sub":"user-1","exp":%d,"iat":%d,"email":"alice@example.com","groups":["devs"]}`,
srv.URL, clientID, now+3600, now))
`{"iss":%q,"aud":%q,"sub":"user-1","exp":%d,"iat":%d,%s}`,
srv.URL, clientID, now+3600, now, userClaims))
digest := sha256.Sum256([]byte(header + "." + payload))
sig, _ := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
idToken := header + "." + payload + "." + b64(sig)
Expand Down Expand Up @@ -245,6 +258,27 @@ var _ = Describe("OIDCProvider handlers", func() {
Expect(rec.Result().Cookies()).NotTo(BeEmpty())
})

It("sends an S256 PKCE challenge derived from the stored verifier", func(ctx SpecContext) {
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/auth/login", nil)
rec := httptest.NewRecorder()

provider.HandleLogin(store)(rec, req)

loc, err := url.Parse(rec.Header().Get("Location"))
Expect(err).NotTo(HaveOccurred())
Expect(loc.Query().Get("code_challenge_method")).To(Equal("S256"))

follow := httptest.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
follow.AddCookie(rec.Result().Cookies()[0])
state, verifier := store.GetState(follow)
Expect(state).To(Equal(loc.Query().Get("state")))
Expect(verifier).NotTo(BeEmpty())

sum := sha256.Sum256([]byte(verifier))
Expect(loc.Query().Get("code_challenge")).
To(Equal(base64.RawURLEncoding.EncodeToString(sum[:])))
})

It("rejects a callback with no code", func(ctx SpecContext) {
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/auth/callback", nil)
rec := httptest.NewRecorder()
Expand All @@ -265,7 +299,7 @@ var _ = Describe("OIDCProvider handlers", func() {

It("rejects a callback whose state does not match", func(ctx SpecContext) {
setState := httptest.NewRecorder()
store.SetState(setState, "expected")
store.SetState(setState, "expected", "verifier")

req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/auth/callback?code=abc&state=wrong", nil)
req.AddCookie(setState.Result().Cookies()[0])
Expand All @@ -279,7 +313,7 @@ var _ = Describe("OIDCProvider handlers", func() {
It("returns 500 when the token exchange fails", func(ctx SpecContext) {
// provider's issuer has no /token endpoint, so Exchange gets a 404.
setState := httptest.NewRecorder()
store.SetState(setState, "s")
store.SetState(setState, "s", "verifier")

req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/auth/callback?code=abc&state=s", nil)
req.AddCookie(setState.Result().Cookies()[0])
Expand All @@ -292,8 +326,9 @@ var _ = Describe("OIDCProvider handlers", func() {
})

var _ = Describe("OIDCProvider callback success", func() {
It("exchanges the code, verifies the id_token, and establishes a session", func(ctx SpecContext) {
idp := fakeIDP("solar")
// login drives the full callback and returns the resulting session.
login := func(ctx SpecContext, userClaims string) *session.Data {
idp := fakeIDP("solar", userClaims)
DeferCleanup(idp.Close)

provider, err := NewOIDCProvider(OIDCConfig{Issuer: idp.URL, ClientID: "solar", RedirectURL: "http://app/cb"})
Expand All @@ -302,7 +337,7 @@ var _ = Describe("OIDCProvider callback success", func() {
Expect(err).NotTo(HaveOccurred())

setState := httptest.NewRecorder()
store.SetState(setState, "s")
store.SetState(setState, "s", "verifier")
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/auth/callback?code=abc&state=s", nil)
req.AddCookie(setState.Result().Cookies()[0])
rec := httptest.NewRecorder()
Expand All @@ -317,11 +352,30 @@ var _ = Describe("OIDCProvider callback success", func() {
for _, c := range rec.Result().Cookies() {
follow.AddCookie(c)
}
sess := store.Get(follow)

return store.Get(follow)
}

It("exchanges the code, verifies the id_token, and establishes a session", func(ctx SpecContext) {
sess := login(ctx, `"email":"alice@example.com","groups":["devs"]`)

Expect(sess).NotTo(BeNil())
Expect(sess.Username).To(Equal("alice@example.com"))
Expect(sess.Groups).To(Equal([]string{"devs"}))
})

It("sends the PKCE verifier to the token endpoint", func(ctx SpecContext) {
login(ctx, `"email":"alice@example.com"`)

Expect(tokenForm.Get("code_verifier")).To(Equal("verifier"))
})

It("leaves groups empty when the token carries no groups claim", func(ctx SpecContext) {
sess := login(ctx, `"email":"alice@example.com"`)

Expect(sess.Username).To(Equal("alice@example.com"))
Expect(sess.Groups).To(BeEmpty())
})
})

var _ = Describe("OIDCProvider.WrapConfig", func() {
Expand Down
Loading
Loading