From 611fea27eb2fb356fb7d140f66fa6a81da65d3af Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Thu, 26 Feb 2026 10:10:06 +0100 Subject: [PATCH 1/2] feat(netbird): structured OIDC/SSO configuration with Keycloak e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a comprehensive oidc.* values section that renders full OIDC/SSO configuration into the server config.yaml, replacing the need for extraEnv hacks. Default oidc.enabled: false — no breaking changes. Configuration sections: - oidc.audience, userIdClaim, configEndpoint, authKeysLocation - oidc.deviceAuthFlow: RFC 8628 device authorization flow - oidc.pkceAuthFlow: RFC 7636 PKCE authorization flow - oidc.idpManager: IdP management (keycloak, auth0, azure, zitadel) Secret injection follows existing pattern: secretName/secretKey for Kubernetes secretKeyRef, with envsubst placeholders in config template. Dashboard AUTH_AUTHORITY now falls back to server.config.auth.issuer when dashboard.config.authAuthority is empty. Testing: - 22 new unit tests (190 total, all passing) - Keycloak OIDC e2e test: deploys Keycloak in-cluster, configures realm/clients/users via REST API, verifies OIDC middleware active Closes #7 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yaml | 35 ++ CHANGELOG.md | 24 ++ Makefile | 6 +- charts/netbird/README.md | 190 +++++++++++ .../netbird/ci/e2e-values-oidc-keycloak.yaml | 82 +++++ charts/netbird/templates/_helpers.tpl | 111 +++++++ .../templates/dashboard-deployment.yaml | 2 +- .../netbird/templates/server-deployment.yaml | 14 + .../tests/dashboard-deployment_test.yaml | 26 ++ .../netbird/tests/server-configmap_test.yaml | 228 +++++++++++++ .../netbird/tests/server-deployment_test.yaml | 76 +++++ charts/netbird/values.yaml | 127 ++++++++ ci/scripts/e2e-oidc.sh | 303 ++++++++++++++++++ 13 files changed, 1222 insertions(+), 2 deletions(-) create mode 100644 charts/netbird/ci/e2e-values-oidc-keycloak.yaml create mode 100755 ci/scripts/e2e-oidc.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 90b92fd..c6114ab 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -140,3 +140,38 @@ jobs: kubectl -n netbird-e2e logs deployment/mysql --tail=50 || true echo "=== Events ===" kubectl -n netbird-e2e get events --sort-by='.lastTimestamp' || true + + e2e-oidc-keycloak: + name: E2E — OIDC (Keycloak) + runs-on: ubuntu-latest + needs: lint-and-unit-test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v4.0.2 + + - name: Create kind cluster + uses: helm/kind-action@v1 + with: + cluster_name: helms-e2e + + - name: Run e2e test (oidc-keycloak) + run: ci/scripts/e2e-oidc.sh keycloak + + - name: Show debug info on failure + if: failure() + run: | + echo "=== Pod status ===" + kubectl -n netbird-e2e get pods -o wide || true + echo "=== Server logs ===" + kubectl -n netbird-e2e logs deployment/netbird-e2e-server --all-containers --tail=100 || true + echo "=== Dashboard logs ===" + kubectl -n netbird-e2e logs deployment/netbird-e2e-dashboard --tail=100 || true + echo "=== Keycloak logs ===" + kubectl -n netbird-e2e logs deployment/keycloak --tail=100 || true + echo "=== Events ===" + kubectl -n netbird-e2e get events --sort-by='.lastTimestamp' || true diff --git a/CHANGELOG.md b/CHANGELOG.md index f89fe36..4c27cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- **OIDC/SSO configuration**: New `oidc.*` values for structured OIDC/SSO + configuration. When `oidc.enabled: true`, the chart renders `http:`, + `deviceAuthFlow:`, `pkceAuthFlow:`, and `idpConfig:` sections in the + server config.yaml. Supports all NetBird-supported IdP managers: keycloak, + auth0, azure, zitadel, okta, authentik, google, jumpcloud, dex, embedded. +- `oidc.audience`, `oidc.userIdClaim`, `oidc.configEndpoint`, + `oidc.authKeysLocation` for HttpServerConfig fields. +- `oidc.deviceAuthFlow.*` for Device Authorization Flow (RFC 8628) — CLI + clients. +- `oidc.pkceAuthFlow.*` for PKCE Authorization Flow (RFC 7636) — dashboard + and web app clients. Supports both plain-text and secret-ref client secrets. +- `oidc.idpManager.*` for IdP Manager configuration (server-side user/group + sync). Provider-specific credentials rendered under the correct YAML key + based on `oidc.idpManager.managerType` (e.g. `keycloakClientCredentials`, + `auth0ClientCredentials`, `azureClientCredentials`). +- OIDC secret values (`IDP_CLIENT_SECRET`, `PKCE_CLIENT_SECRET`) injected + via Kubernetes Secrets using the existing Initium render pipeline. +- Dashboard `AUTH_AUTHORITY` falls back to `server.config.auth.issuer` when + `dashboard.config.authAuthority` is empty. +- E2E test with Keycloak deployed in-cluster: verifies OIDC middleware, + token acquisition via direct grant, and authenticated API access. +- Unit tests for OIDC config rendering, secret injection, provider + credentials key mapping, and dashboard fallback (190 tests total). + - **PAT seeding**: Optional Personal Access Token seeding via `pat.*` values. When `pat.enabled: true`, a service user account and PAT are seeded into the database using Initium's `seed` command. The seed waits for the server diff --git a/Makefile b/Makefile index 946b667..28d1ef8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint unittest e2e e2e-sqlite e2e-postgres e2e-mysql e2e-setup e2e-teardown test +.PHONY: lint unittest e2e e2e-sqlite e2e-postgres e2e-mysql e2e-oidc-keycloak e2e-setup e2e-teardown test CHARTS := $(wildcard charts/*) @@ -35,10 +35,14 @@ e2e-postgres: e2e-setup e2e-mysql: e2e-setup ci/scripts/e2e.sh mysql +e2e-oidc-keycloak: e2e-setup + ci/scripts/e2e-oidc.sh keycloak + e2e: e2e-setup ci/scripts/e2e.sh sqlite ci/scripts/e2e.sh postgres ci/scripts/e2e.sh mysql + ci/scripts/e2e-oidc.sh keycloak e2e-teardown: kind delete cluster --name $(E2E_CLUSTER) 2>/dev/null || true diff --git a/charts/netbird/README.md b/charts/netbird/README.md index 46c71e1..29f674e 100644 --- a/charts/netbird/README.md +++ b/charts/netbird/README.md @@ -237,6 +237,152 @@ In both cases, the seed: curl -H "Authorization: Token nbp_..." https://netbird.example.com/api/groups ``` +## OIDC / SSO Configuration + +The chart supports structured OIDC configuration for integrating with +external identity providers. When `oidc.enabled: true`, the chart renders +`http:`, `deviceAuthFlow:`, `pkceAuthFlow:`, and `idpConfig:` sections into +the server config.yaml. + +### Keycloak Example + +```yaml +server: + config: + auth: + issuer: "https://keycloak.example.com/realms/netbird" + +oidc: + enabled: true + audience: "netbird" + userIdClaim: "sub" + configEndpoint: "https://keycloak.example.com/realms/netbird/.well-known/openid-configuration" + + deviceAuthFlow: + enabled: true + provider: "keycloak" + providerConfig: + clientId: "netbird-client" + domain: "keycloak.example.com" + tokenEndpoint: "https://keycloak.example.com/realms/netbird/protocol/openid-connect/token" + deviceAuthEndpoint: "https://keycloak.example.com/realms/netbird/protocol/openid-connect/auth/device" + scope: "openid profile email" + + pkceAuthFlow: + enabled: true + providerConfig: + clientId: "netbird-dashboard" + authorizationEndpoint: "https://keycloak.example.com/realms/netbird/protocol/openid-connect/auth" + tokenEndpoint: "https://keycloak.example.com/realms/netbird/protocol/openid-connect/token" + scope: "openid profile email groups offline_access" + redirectUrls: + - "https://netbird.example.com/nb-auth" + - "https://netbird.example.com/nb-silent-auth" + + idpManager: + enabled: true + managerType: "keycloak" + clientConfig: + issuer: "https://keycloak.example.com/realms/netbird" + tokenEndpoint: "https://keycloak.example.com/realms/netbird/protocol/openid-connect/token" + clientId: "netbird-backend" + clientSecret: + secretName: keycloak-client-secret + secretKey: clientSecret + grantType: "client_credentials" +``` + +### Auth0 Example + +```yaml +oidc: + enabled: true + audience: "netbird-api" + deviceAuthFlow: + enabled: true + provider: "auth0" + providerConfig: + clientId: "" + domain: ".auth0.com" + audience: "netbird-api" + idpManager: + enabled: true + managerType: "auth0" + clientConfig: + issuer: "https://.auth0.com/" + tokenEndpoint: "https://.auth0.com/oauth/token" + clientId: "" + clientSecret: + secretName: auth0-client-secret + secretKey: clientSecret + grantType: "client_credentials" + providerConfig: + Audience: "https://.auth0.com/api/v2/" + AuthIssuer: "https://.auth0.com/" +``` + +### Azure Entra ID Example + +```yaml +oidc: + enabled: true + audience: "api://" + userIdClaim: "oid" + deviceAuthFlow: + enabled: true + provider: "azure" + providerConfig: + clientId: "" + domain: "login.microsoftonline.com" + audience: "api://" + idpManager: + enabled: true + managerType: "azure" + clientConfig: + issuer: "https://login.microsoftonline.com//v2.0" + tokenEndpoint: "https://login.microsoftonline.com//oauth2/v2.0/token" + clientId: "" + clientSecret: + secretName: azure-client-secret + secretKey: clientSecret + grantType: "client_credentials" + providerConfig: + ObjectID: "" + GraphAPIEndpoint: "https://graph.microsoft.com" +``` + +### Secret Injection + +OIDC client secrets are injected via Kubernetes Secrets and never stored in +ConfigMaps. Create a Secret for your IdP manager client: + +```bash +kubectl create secret generic keycloak-client-secret \ + --from-literal=clientSecret='your-client-secret' \ + -n netbird +``` + +The chart injects the secret as an environment variable (`IDP_CLIENT_SECRET` +or `PKCE_CLIENT_SECRET`) in the config-init container, and references it in +the config template as `${IDP_CLIENT_SECRET}` / `${PKCE_CLIENT_SECRET}`. + +### Dashboard Auto-Derivation + +When `dashboard.config.authAuthority` is empty, the dashboard automatically +uses `server.config.auth.issuer` as the OIDC authority. You should still set +`dashboard.config.authClientId` and `dashboard.config.authAudience` explicitly. + +### Manual Testing for SaaS Providers + +Providers that cannot be deployed in-cluster (Azure Entra ID, Auth0, Okta, +ADFS) can be tested manually: + +1. Configure the provider with appropriate app registrations +2. Create Kubernetes Secrets with the client credentials +3. Install the chart with provider-specific OIDC values +4. Verify: `kubectl logs` shows the server connecting to the IdP, + `curl -H "Authorization: Bearer " .../api/users` returns 200 + ## Values Reference ### Global @@ -263,6 +409,50 @@ curl -H "Authorization: Token nbp_..." https://netbird.example.com/api/groups | `database.passwordSecret.secretKey` | string | `"password"` | Key in the Secret | | `database.sslMode` | string | `"disable"` | SSL mode for PostgreSQL (ignored for mysql/sqlite) | +### OIDC / SSO + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `oidc.enabled` | bool | `false` | Enable OIDC configuration | +| `oidc.audience` | string | `""` | JWT audience claim (HttpServerConfig.AuthAudience) | +| `oidc.userIdClaim` | string | `""` | JWT user ID claim (default: "sub") | +| `oidc.configEndpoint` | string | `""` | OIDC discovery endpoint URL | +| `oidc.authKeysLocation` | string | `""` | JWT keys location URL (JWKS) | +| `oidc.deviceAuthFlow.enabled` | bool | `false` | Enable device authorization flow (CLI) | +| `oidc.deviceAuthFlow.provider` | string | `"hosted"` | Device auth provider name | +| `oidc.deviceAuthFlow.providerConfig.clientId` | string | `""` | Client ID for CLI app | +| `oidc.deviceAuthFlow.providerConfig.clientSecret` | string | `""` | Client secret (usually empty for public) | +| `oidc.deviceAuthFlow.providerConfig.domain` | string | `""` | Provider domain | +| `oidc.deviceAuthFlow.providerConfig.audience` | string | `""` | Audience for token validation | +| `oidc.deviceAuthFlow.providerConfig.tokenEndpoint` | string | `""` | Token endpoint override | +| `oidc.deviceAuthFlow.providerConfig.deviceAuthEndpoint` | string | `""` | Device auth endpoint override | +| `oidc.deviceAuthFlow.providerConfig.scope` | string | `"openid"` | OAuth2 scopes | +| `oidc.deviceAuthFlow.providerConfig.useIdToken` | bool | `false` | Use ID token instead of access token | +| `oidc.pkceAuthFlow.enabled` | bool | `false` | Enable PKCE authorization flow (dashboard) | +| `oidc.pkceAuthFlow.providerConfig.clientId` | string | `""` | Client ID for dashboard app | +| `oidc.pkceAuthFlow.providerConfig.clientSecret.value` | string | `""` | Plain-text client secret | +| `oidc.pkceAuthFlow.providerConfig.clientSecret.secretName` | string | `""` | Secret name for client secret | +| `oidc.pkceAuthFlow.providerConfig.clientSecret.secretKey` | string | `"clientSecret"` | Key in Secret | +| `oidc.pkceAuthFlow.providerConfig.domain` | string | `""` | Provider domain | +| `oidc.pkceAuthFlow.providerConfig.audience` | string | `""` | Audience | +| `oidc.pkceAuthFlow.providerConfig.authorizationEndpoint` | string | `""` | Authorization endpoint override | +| `oidc.pkceAuthFlow.providerConfig.tokenEndpoint` | string | `""` | Token endpoint override | +| `oidc.pkceAuthFlow.providerConfig.scope` | string | `"openid profile email"` | OAuth2 scopes | +| `oidc.pkceAuthFlow.providerConfig.redirectUrls` | list | `[]` | Allowed redirect URLs | +| `oidc.pkceAuthFlow.providerConfig.useIdToken` | bool | `false` | Use ID token | +| `oidc.pkceAuthFlow.providerConfig.disablePromptLogin` | bool | `false` | Disable login prompt | +| `oidc.pkceAuthFlow.providerConfig.loginFlag` | int | `0` | Login flag value | +| `oidc.idpManager.enabled` | bool | `false` | Enable IdP manager for user sync | +| `oidc.idpManager.managerType` | string | `""` | Manager type (keycloak, auth0, azure, zitadel, okta, etc.) | +| `oidc.idpManager.clientConfig.issuer` | string | `""` | OIDC issuer for management API | +| `oidc.idpManager.clientConfig.tokenEndpoint` | string | `""` | Token endpoint | +| `oidc.idpManager.clientConfig.clientId` | string | `""` | Client ID | +| `oidc.idpManager.clientConfig.clientSecret.secretName` | string | `""` | Secret name for client secret | +| `oidc.idpManager.clientConfig.clientSecret.secretKey` | string | `"clientSecret"` | Key in Secret | +| `oidc.idpManager.clientConfig.grantType` | string | `"client_credentials"` | OAuth2 grant type | +| `oidc.idpManager.extraConfig` | object | `{}` | Provider-specific extra config | +| `oidc.idpManager.providerConfig` | object | `{}` | Provider-specific credentials | + ### PAT (Personal Access Token) | Key | Type | Default | Description | diff --git a/charts/netbird/ci/e2e-values-oidc-keycloak.yaml b/charts/netbird/ci/e2e-values-oidc-keycloak.yaml new file mode 100644 index 0000000..e447c22 --- /dev/null +++ b/charts/netbird/ci/e2e-values-oidc-keycloak.yaml @@ -0,0 +1,82 @@ +# E2E test values — OIDC with Keycloak deployed in-cluster. +# +# Keycloak is deployed as a simple pod in the same namespace. +# The test configures a "netbird" realm with: +# - netbird-client (public, direct grant for testing) +# - netbird-backend (confidential, service accounts for IdP manager) +# - A test user for token acquisition + +database: + type: sqlite + +server: + persistentVolume: + enabled: false + + stunService: + type: ClusterIP + + config: + exposedAddress: "https://netbird.localhost" + auth: + issuer: "http://keycloak.netbird-e2e.svc.cluster.local:8080/realms/netbird" + dashboardRedirectURIs: + - "https://netbird.localhost/nb-auth" + - "https://netbird.localhost/nb-silent-auth" + + livenessProbe: + failureThreshold: 20 + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 3 + tcpSocket: + port: http + readinessProbe: + failureThreshold: 20 + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 3 + tcpSocket: + port: http + +oidc: + enabled: true + audience: "netbird" + userIdClaim: "sub" + configEndpoint: "http://keycloak.netbird-e2e.svc.cluster.local:8080/realms/netbird/.well-known/openid-configuration" + + deviceAuthFlow: + enabled: false + + pkceAuthFlow: + enabled: true + providerConfig: + audience: "netbird" + clientId: "netbird-client" + clientSecret: + value: "" + tokenEndpoint: "http://keycloak.netbird-e2e.svc.cluster.local:8080/realms/netbird/protocol/openid-connect/token" + authorizationEndpoint: "http://keycloak.netbird-e2e.svc.cluster.local:8080/realms/netbird/protocol/openid-connect/auth" + scope: "openid profile email" + redirectUrls: + - "http://localhost:53000/" + + idpManager: + enabled: true + managerType: "keycloak" + clientConfig: + issuer: "http://keycloak.netbird-e2e.svc.cluster.local:8080/realms/netbird" + tokenEndpoint: "http://keycloak.netbird-e2e.svc.cluster.local:8080/realms/netbird/protocol/openid-connect/token" + clientId: "netbird-backend" + clientSecret: + secretName: netbird-idp-secret + secretKey: clientSecret + grantType: "client_credentials" + +dashboard: + config: + mgmtApiEndpoint: "https://netbird.localhost" + mgmtGrpcApiEndpoint: "https://netbird.localhost" + authAuthority: "http://keycloak.netbird-e2e.svc.cluster.local:8080/realms/netbird" + authClientId: "netbird-client" + authAudience: "netbird" diff --git a/charts/netbird/templates/_helpers.tpl b/charts/netbird/templates/_helpers.tpl index c7d33c2..60f74f0 100644 --- a/charts/netbird/templates/_helpers.tpl +++ b/charts/netbird/templates/_helpers.tpl @@ -155,6 +155,26 @@ netbird.database.isExternal — true when database.type is not sqlite. {{- ne .Values.database.type "sqlite" -}} {{- end }} +{{/* ===== OIDC helpers ===== */}} + +{{/* +netbird.oidc.providerCredentialsKey — maps idpManager.managerType to the +corresponding YAML key for provider-specific credentials in config.yaml. + auth0 -> auth0ClientCredentials + azure -> azureClientCredentials + keycloak -> keycloakClientCredentials + zitadel -> zitadelClientCredentials + (other) -> ClientCredentials +*/}} +{{- define "netbird.oidc.providerCredentialsKey" -}} +{{- if eq . "auth0" -}}auth0ClientCredentials +{{- else if eq . "azure" -}}azureClientCredentials +{{- else if eq . "keycloak" -}}keycloakClientCredentials +{{- else if eq . "zitadel" -}}zitadelClientCredentials +{{- else -}}{{ . }}ClientCredentials +{{- end -}} +{{- end }} + {{/* netbird.database.dsn — constructs the DSN string with ${DB_PASSWORD} placeholder. postgresql: host=H user=U password=${DB_PASSWORD} dbname=D port=P sslmode=S @@ -213,6 +233,97 @@ server: engine: {{ include "netbird.database.engine" . | quote }} dsn: {{ if eq (include "netbird.database.isExternal" .) "true" }}"{{ include "netbird.database.dsn" . }}"{{ else }}""{{ end }} encryptionKey: "${ENCRYPTION_KEY}" +{{- if .Values.oidc.enabled }} + + http: + authAudience: {{ include "netbird.escapeEnvsubst" .Values.oidc.audience | quote }} + {{- with .Values.oidc.userIdClaim }} + authUserIDClaim: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + {{- with .Values.oidc.configEndpoint }} + oidcConfigEndpoint: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + {{- with .Values.oidc.authKeysLocation }} + authKeysLocation: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + idpSignKeyRefreshEnabled: {{ .Values.server.config.auth.signKeyRefreshEnabled }} +{{- if .Values.oidc.deviceAuthFlow.enabled }} + + deviceAuthFlow: + provider: {{ include "netbird.escapeEnvsubst" .Values.oidc.deviceAuthFlow.provider | quote }} + providerConfig: + {{- with .Values.oidc.deviceAuthFlow.providerConfig.audience }} + audience: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + clientId: {{ include "netbird.escapeEnvsubst" .Values.oidc.deviceAuthFlow.providerConfig.clientId | quote }} + {{- with .Values.oidc.deviceAuthFlow.providerConfig.clientSecret }} + clientSecret: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + {{- with .Values.oidc.deviceAuthFlow.providerConfig.domain }} + domain: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + {{- with .Values.oidc.deviceAuthFlow.providerConfig.tokenEndpoint }} + tokenEndpoint: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + {{- with .Values.oidc.deviceAuthFlow.providerConfig.deviceAuthEndpoint }} + deviceAuthEndpoint: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + scope: {{ include "netbird.escapeEnvsubst" .Values.oidc.deviceAuthFlow.providerConfig.scope | quote }} + useIdToken: {{ .Values.oidc.deviceAuthFlow.providerConfig.useIdToken }} +{{- end }} +{{- if .Values.oidc.pkceAuthFlow.enabled }} + + pkceAuthFlow: + providerConfig: + {{- with .Values.oidc.pkceAuthFlow.providerConfig.audience }} + audience: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + clientId: {{ include "netbird.escapeEnvsubst" .Values.oidc.pkceAuthFlow.providerConfig.clientId | quote }} + {{- if .Values.oidc.pkceAuthFlow.providerConfig.clientSecret.secretName }} + clientSecret: "${PKCE_CLIENT_SECRET}" + {{- else }} + clientSecret: {{ include "netbird.escapeEnvsubst" .Values.oidc.pkceAuthFlow.providerConfig.clientSecret.value | quote }} + {{- end }} + {{- with .Values.oidc.pkceAuthFlow.providerConfig.domain }} + domain: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + {{- with .Values.oidc.pkceAuthFlow.providerConfig.authorizationEndpoint }} + authorizationEndpoint: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + {{- with .Values.oidc.pkceAuthFlow.providerConfig.tokenEndpoint }} + tokenEndpoint: {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + scope: {{ include "netbird.escapeEnvsubst" .Values.oidc.pkceAuthFlow.providerConfig.scope | quote }} + {{- with .Values.oidc.pkceAuthFlow.providerConfig.redirectUrls }} + redirectURLs: + {{- range . }} + - {{ include "netbird.escapeEnvsubst" . | quote }} + {{- end }} + {{- end }} + useIdToken: {{ .Values.oidc.pkceAuthFlow.providerConfig.useIdToken }} + disablePromptLogin: {{ .Values.oidc.pkceAuthFlow.providerConfig.disablePromptLogin }} + loginFlag: {{ .Values.oidc.pkceAuthFlow.providerConfig.loginFlag }} +{{- end }} +{{- if .Values.oidc.idpManager.enabled }} + + idpConfig: + managerType: {{ include "netbird.escapeEnvsubst" .Values.oidc.idpManager.managerType | quote }} + clientConfig: + issuer: {{ include "netbird.escapeEnvsubst" .Values.oidc.idpManager.clientConfig.issuer | quote }} + tokenEndpoint: {{ include "netbird.escapeEnvsubst" .Values.oidc.idpManager.clientConfig.tokenEndpoint | quote }} + clientId: {{ include "netbird.escapeEnvsubst" .Values.oidc.idpManager.clientConfig.clientId | quote }} + clientSecret: "${IDP_CLIENT_SECRET}" + grantType: {{ include "netbird.escapeEnvsubst" .Values.oidc.idpManager.clientConfig.grantType | quote }} + {{- with .Values.oidc.idpManager.extraConfig }} + extraConfig: + {{- toYaml . | nindent 6 }} + {{- end }} + {{- with .Values.oidc.idpManager.providerConfig }} + {{ include "netbird.oidc.providerCredentialsKey" $.Values.oidc.idpManager.managerType }}: + {{- toYaml . | nindent 6 }} + {{- end }} +{{- end }} +{{- end }} {{- end }} {{/* diff --git a/charts/netbird/templates/dashboard-deployment.yaml b/charts/netbird/templates/dashboard-deployment.yaml index 017ebe3..a5fbf20 100644 --- a/charts/netbird/templates/dashboard-deployment.yaml +++ b/charts/netbird/templates/dashboard-deployment.yaml @@ -59,7 +59,7 @@ spec: value: {{ .Values.dashboard.secrets.authClientSecret.value | quote }} {{- end }} - name: AUTH_AUTHORITY - value: {{ .Values.dashboard.config.authAuthority | quote }} + value: {{ (coalesce .Values.dashboard.config.authAuthority .Values.server.config.auth.issuer) | quote }} - name: USE_AUTH0 value: {{ .Values.dashboard.config.useAuth0 | quote }} - name: AUTH_SUPPORTED_SCOPES diff --git a/charts/netbird/templates/server-deployment.yaml b/charts/netbird/templates/server-deployment.yaml index 96c4385..bda7138 100644 --- a/charts/netbird/templates/server-deployment.yaml +++ b/charts/netbird/templates/server-deployment.yaml @@ -124,6 +124,20 @@ spec: name: {{ .Values.database.passwordSecret.secretName }} key: {{ .Values.database.passwordSecret.secretKey }} {{- end }} + {{- if and .Values.oidc.enabled .Values.oidc.idpManager.enabled .Values.oidc.idpManager.clientConfig.clientSecret.secretName }} + - name: IDP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.oidc.idpManager.clientConfig.clientSecret.secretName }} + key: {{ .Values.oidc.idpManager.clientConfig.clientSecret.secretKey }} + {{- end }} + {{- if and .Values.oidc.enabled .Values.oidc.pkceAuthFlow.enabled .Values.oidc.pkceAuthFlow.providerConfig.clientSecret.secretName }} + - name: PKCE_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.oidc.pkceAuthFlow.providerConfig.clientSecret.secretName }} + key: {{ .Values.oidc.pkceAuthFlow.providerConfig.clientSecret.secretKey }} + {{- end }} securityContext: runAsNonRoot: true runAsUser: 65534 diff --git a/charts/netbird/tests/dashboard-deployment_test.yaml b/charts/netbird/tests/dashboard-deployment_test.yaml index 6fab2f8..9896974 100644 --- a/charts/netbird/tests/dashboard-deployment_test.yaml +++ b/charts/netbird/tests/dashboard-deployment_test.yaml @@ -180,6 +180,32 @@ tests: content: sidecar.istio.io/inject: "false" + # ── AUTH_AUTHORITY fallback ─────────────────────────────────────────── + + - it: should use server auth issuer when dashboard authAuthority is empty + set: + dashboard.config.authAuthority: "" + server.config.auth.issuer: "https://auth.example.com" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: AUTH_AUTHORITY + value: "https://auth.example.com" + + - it: should prefer explicit dashboard authAuthority over server issuer + set: + dashboard.config.authAuthority: "https://custom.auth.com" + server.config.auth.issuer: "https://auth.example.com" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: AUTH_AUTHORITY + value: "https://custom.auth.com" + + # ── Other settings ───────────────────────────────────────────────── + - it: should include liveness and readiness probes by default asserts: - equal: diff --git a/charts/netbird/tests/server-configmap_test.yaml b/charts/netbird/tests/server-configmap_test.yaml index 8c5127a..d30dc5f 100644 --- a/charts/netbird/tests/server-configmap_test.yaml +++ b/charts/netbird/tests/server-configmap_test.yaml @@ -106,3 +106,231 @@ tests: app.kubernetes.io/name: netbird app.kubernetes.io/component: server + # ── OIDC configuration ──────────────────────────────────────────────── + + - it: should not render OIDC sections when oidc.enabled is false + asserts: + - notMatchRegex: + path: data["config.yaml.tpl"] + pattern: "idpConfig:" + - notMatchRegex: + path: data["config.yaml.tpl"] + pattern: "deviceAuthFlow:" + - notMatchRegex: + path: data["config.yaml.tpl"] + pattern: "pkceAuthFlow:" + + - it: should render http section when oidc is enabled + set: + oidc.enabled: true + oidc.audience: "my-api" + oidc.userIdClaim: "email" + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'authAudience: "my-api"' + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'authUserIDClaim: "email"' + - matchRegex: + path: data["config.yaml.tpl"] + pattern: "idpSignKeyRefreshEnabled: true" + + - it: should render oidcConfigEndpoint when set + set: + oidc.enabled: true + oidc.configEndpoint: "https://auth.example.com/.well-known/openid-configuration" + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'oidcConfigEndpoint: "https://auth\.example\.com/\.well-known/openid-configuration"' + + - it: should not render oidcConfigEndpoint when empty + set: + oidc.enabled: true + asserts: + - notMatchRegex: + path: data["config.yaml.tpl"] + pattern: "oidcConfigEndpoint:" + + - it: should render authKeysLocation when set + set: + oidc.enabled: true + oidc.authKeysLocation: "https://auth.example.com/.well-known/jwks.json" + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'authKeysLocation: "https://auth\.example\.com/\.well-known/jwks\.json"' + + - it: should render deviceAuthFlow when enabled + set: + oidc.enabled: true + oidc.deviceAuthFlow.enabled: true + oidc.deviceAuthFlow.provider: "keycloak" + oidc.deviceAuthFlow.providerConfig.clientId: "cli-client" + oidc.deviceAuthFlow.providerConfig.domain: "auth.example.com" + oidc.deviceAuthFlow.providerConfig.audience: "my-api" + oidc.deviceAuthFlow.providerConfig.tokenEndpoint: "https://auth.example.com/token" + oidc.deviceAuthFlow.providerConfig.deviceAuthEndpoint: "https://auth.example.com/device" + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'provider: "keycloak"' + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'clientId: "cli-client"' + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'domain: "auth\.example\.com"' + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'audience: "my-api"' + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'tokenEndpoint: "https://auth\.example\.com/token"' + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'deviceAuthEndpoint: "https://auth\.example\.com/device"' + + - it: should not render deviceAuthFlow when disabled + set: + oidc.enabled: true + oidc.deviceAuthFlow.enabled: false + asserts: + - notMatchRegex: + path: data["config.yaml.tpl"] + pattern: "deviceAuthFlow:" + + - it: should render pkceAuthFlow with plain-text client secret + set: + oidc.enabled: true + oidc.pkceAuthFlow.enabled: true + oidc.pkceAuthFlow.providerConfig.clientId: "dashboard" + oidc.pkceAuthFlow.providerConfig.clientSecret.value: "my-plain-secret" + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: "pkceAuthFlow:" + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'clientSecret: "my-plain-secret"' + + - it: should render pkceAuthFlow with secret ref placeholder + set: + oidc.enabled: true + oidc.pkceAuthFlow.enabled: true + oidc.pkceAuthFlow.providerConfig.clientId: "dashboard" + oidc.pkceAuthFlow.providerConfig.clientSecret.secretName: my-pkce-secret + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'clientSecret: "\$\{PKCE_CLIENT_SECRET\}"' + + - it: should render pkceAuthFlow redirectURLs + set: + oidc.enabled: true + oidc.pkceAuthFlow.enabled: true + oidc.pkceAuthFlow.providerConfig.clientId: "dashboard" + oidc.pkceAuthFlow.providerConfig.redirectUrls: + - "https://netbird.example.com/nb-auth" + - "https://netbird.example.com/nb-silent-auth" + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: "redirectURLs:" + - matchRegex: + path: data["config.yaml.tpl"] + pattern: '"https://netbird\.example\.com/nb-auth"' + + - it: should render idpConfig with keycloak provider credentials key + set: + oidc.enabled: true + oidc.idpManager.enabled: true + oidc.idpManager.managerType: "keycloak" + oidc.idpManager.clientConfig.issuer: "https://kc.example.com/realms/nb" + oidc.idpManager.clientConfig.tokenEndpoint: "https://kc.example.com/realms/nb/protocol/openid-connect/token" + oidc.idpManager.clientConfig.clientId: "backend" + oidc.idpManager.clientConfig.clientSecret.secretName: my-kc-secret + oidc.idpManager.providerConfig: + adminEndpoint: "https://kc.example.com/admin/realms/nb" + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'managerType: "keycloak"' + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'clientSecret: "\$\{IDP_CLIENT_SECRET\}"' + - matchRegex: + path: data["config.yaml.tpl"] + pattern: "keycloakClientCredentials:" + - matchRegex: + path: data["config.yaml.tpl"] + pattern: "adminEndpoint:" + + - it: should render auth0ClientCredentials for auth0 manager type + set: + oidc.enabled: true + oidc.idpManager.enabled: true + oidc.idpManager.managerType: "auth0" + oidc.idpManager.clientConfig.issuer: "https://tenant.auth0.com/" + oidc.idpManager.clientConfig.tokenEndpoint: "https://tenant.auth0.com/oauth/token" + oidc.idpManager.clientConfig.clientId: "m2m-client" + oidc.idpManager.clientConfig.clientSecret.secretName: my-auth0-secret + oidc.idpManager.providerConfig: + Audience: "https://tenant.auth0.com/api/v2/" + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: "auth0ClientCredentials:" + - notMatchRegex: + path: data["config.yaml.tpl"] + pattern: "keycloakClientCredentials:" + + - it: should render azureClientCredentials for azure manager type + set: + oidc.enabled: true + oidc.idpManager.enabled: true + oidc.idpManager.managerType: "azure" + oidc.idpManager.clientConfig.issuer: "https://login.microsoftonline.com/tenant/v2.0" + oidc.idpManager.clientConfig.tokenEndpoint: "https://login.microsoftonline.com/tenant/oauth2/v2.0/token" + oidc.idpManager.clientConfig.clientId: "client-id" + oidc.idpManager.clientConfig.clientSecret.secretName: my-azure-secret + oidc.idpManager.providerConfig: + ObjectID: "service-principal-oid" + GraphAPIEndpoint: "https://graph.microsoft.com" + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: "azureClientCredentials:" + - matchRegex: + path: data["config.yaml.tpl"] + pattern: "ObjectID: service-principal-oid" + + - it: should render idpManager extraConfig + set: + oidc.enabled: true + oidc.idpManager.enabled: true + oidc.idpManager.managerType: "keycloak" + oidc.idpManager.clientConfig.issuer: "https://kc.example.com" + oidc.idpManager.clientConfig.tokenEndpoint: "https://kc.example.com/token" + oidc.idpManager.clientConfig.clientId: "backend" + oidc.idpManager.clientConfig.clientSecret.secretName: my-secret + oidc.idpManager.extraConfig: + customKey: "customValue" + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: "extraConfig:" + - matchRegex: + path: data["config.yaml.tpl"] + pattern: "customKey: customValue" + + - it: should escape dollar signs in OIDC values + set: + oidc.enabled: true + oidc.audience: "my$api" + asserts: + - matchRegex: + path: data["config.yaml.tpl"] + pattern: 'authAudience: "my\$\{DOLLAR\}api"' + diff --git a/charts/netbird/tests/server-deployment_test.yaml b/charts/netbird/tests/server-deployment_test.yaml index a5402aa..2a881a8 100644 --- a/charts/netbird/tests/server-deployment_test.yaml +++ b/charts/netbird/tests/server-deployment_test.yaml @@ -533,6 +533,82 @@ tests: - notExists: path: spec.template.metadata.annotations.checksum/pat-seed + # ── OIDC secret injection ──────────────────────────────────────────── + + - it: should not inject IDP_CLIENT_SECRET when oidc is disabled + asserts: + - notContains: + path: spec.template.spec.initContainers[0].env + content: + name: IDP_CLIENT_SECRET + any: true + + - it: should not inject PKCE_CLIENT_SECRET when oidc is disabled + asserts: + - notContains: + path: spec.template.spec.initContainers[0].env + content: + name: PKCE_CLIENT_SECRET + any: true + + - it: should inject IDP_CLIENT_SECRET when idpManager is enabled + set: + oidc.enabled: true + oidc.idpManager.enabled: true + oidc.idpManager.managerType: "keycloak" + oidc.idpManager.clientConfig.clientSecret.secretName: my-idp-secret + oidc.idpManager.clientConfig.clientSecret.secretKey: myKey + asserts: + - contains: + path: spec.template.spec.initContainers[0].env + content: + name: IDP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: my-idp-secret + key: myKey + + - it: should inject PKCE_CLIENT_SECRET when pkceAuthFlow uses secret ref + set: + oidc.enabled: true + oidc.pkceAuthFlow.enabled: true + oidc.pkceAuthFlow.providerConfig.clientSecret.secretName: my-pkce-secret + oidc.pkceAuthFlow.providerConfig.clientSecret.secretKey: pkceKey + asserts: + - contains: + path: spec.template.spec.initContainers[0].env + content: + name: PKCE_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: my-pkce-secret + key: pkceKey + + - it: should not inject PKCE_CLIENT_SECRET when using plain-text secret + set: + oidc.enabled: true + oidc.pkceAuthFlow.enabled: true + oidc.pkceAuthFlow.providerConfig.clientSecret.value: "plain-secret" + asserts: + - notContains: + path: spec.template.spec.initContainers[0].env + content: + name: PKCE_CLIENT_SECRET + any: true + + - it: should not change init container count when oidc is enabled with sqlite + set: + oidc.enabled: true + oidc.idpManager.enabled: true + oidc.idpManager.clientConfig.clientSecret.secretName: my-secret + asserts: + - lengthEqual: + path: spec.template.spec.initContainers + count: 1 + - equal: + path: spec.template.spec.initContainers[0].name + value: config-init + # ── Other deployment settings ──────────────────────────────────────── - it: should set resource limits when specified diff --git a/charts/netbird/values.yaml b/charts/netbird/values.yaml index d6f9347..cf66eed 100644 --- a/charts/netbird/values.yaml +++ b/charts/netbird/values.yaml @@ -76,6 +76,133 @@ database: # verify-full). Ignored for mysql and sqlite. sslMode: "disable" +# ============================================================================= +# OIDC / SSO Configuration +# ============================================================================= +# Structured OIDC configuration for integrating with external identity +# providers. When oidc.enabled is true, the chart renders HttpServerConfig, +# DeviceAuthorizationFlow, PKCEAuthorizationFlow, and IdpManagerConfig +# sections into the server config.yaml. +# +# Supported providers: keycloak, auth0, azure, zitadel, okta, authentik, +# google, jumpcloud, dex, embedded. +# +# Secret values (IdP client secret, PKCE client secret) are injected via +# Kubernetes Secrets using the existing Initium render pipeline — they never +# appear in ConfigMaps. +# ============================================================================= +oidc: + # -- Enable OIDC configuration. When true, the chart renders IdP manager, + # device auth flow, and PKCE flow settings into the server config. + enabled: false + + # -- The audience claim expected in JWT tokens (HttpServerConfig.AuthAudience). + # Often the OIDC client ID or a custom API identifier. + audience: "" + + # -- The JWT claim used to identify the user (HttpServerConfig.AuthUserIDClaim). + # Common values: "sub" (default), "email", "oid" (Azure). + userIdClaim: "" + + # -- OIDC configuration endpoint for auto-discovery + # (HttpServerConfig.OIDCConfigEndpoint). + # Usually "https:///.well-known/openid-configuration". + # Leave empty to let NetBird derive from auth.issuer. + configEndpoint: "" + + # -- JWT keys location URL (HttpServerConfig.AuthKeysLocation). + # If empty, NetBird auto-discovers from the OIDC config endpoint. + authKeysLocation: "" + + # ── Device Authorization Flow (RFC 8628) — CLI clients ────────────── + deviceAuthFlow: + # -- Enable device authorization flow for CLI authentication. + enabled: false + # -- Provider name (e.g. "hosted", "none", "auth0", "azure"). + provider: "hosted" + providerConfig: + # -- OIDC client ID for the CLI/device application. + clientId: "" + # -- Client secret (usually empty for public clients). + clientSecret: "" + # -- Domain of the OIDC provider (e.g. "your-tenant.auth0.com"). + domain: "" + # -- Audience for token validation. + audience: "" + # -- Override token endpoint (auto-discovered if empty). + tokenEndpoint: "" + # -- Override device authorization endpoint. + deviceAuthEndpoint: "" + # -- OAuth2 scopes to request. + scope: "openid" + # -- Use the ID token instead of the access token. + useIdToken: false + + # ── PKCE Authorization Flow (RFC 7636) — dashboard/app clients ────── + pkceAuthFlow: + # -- Enable PKCE authorization flow for dashboard authentication. + enabled: false + providerConfig: + # -- OIDC client ID for the dashboard application. + clientId: "" + # -- Client secret. If secretName is set, the value is injected via + # secretKeyRef env var; otherwise the plain-text value is used. + clientSecret: + # -- Plain-text client secret (for public clients, leave empty). + value: "" + # -- Name of an existing Kubernetes Secret containing the client secret. + secretName: "" + # -- Key within the Secret. + secretKey: "clientSecret" + # -- Domain of the OIDC provider. + domain: "" + # -- Audience for token validation. + audience: "" + # -- Override authorization endpoint. + authorizationEndpoint: "" + # -- Override token endpoint. + tokenEndpoint: "" + # -- OAuth2 scopes to request. + scope: "openid profile email" + # -- Allowed redirect URLs for the PKCE flow. + redirectUrls: [] + # -- Use the ID token instead of the access token. + useIdToken: false + # -- Disable the login prompt even if the user has an active session. + disablePromptLogin: false + # -- Login flag value (0 = default, see NetBird docs). + loginFlag: 0 + + # ── IdP Manager — server-side user/group management ───────────────── + idpManager: + # -- Enable IdP manager for server-side user sync. + enabled: false + # -- Manager type: "keycloak", "auth0", "azure", "zitadel", "okta", + # "authentik", "google", "jumpcloud", "dex", "embedded". + managerType: "" + clientConfig: + # -- OIDC issuer URL (often same as server.config.auth.issuer). + issuer: "" + # -- Token endpoint for management API access. + tokenEndpoint: "" + # -- Client ID for the management API application. + clientId: "" + # -- Kubernetes Secret containing the management API client secret. + clientSecret: + # -- Name of the Secret. + secretName: "" + # -- Key within the Secret. + secretKey: "clientSecret" + # -- OAuth2 grant type (usually "client_credentials"). + grantType: "client_credentials" + # -- Provider-specific extra configuration (key-value map). + # Passed as-is to the IdP manager. Keys depend on managerType. + extraConfig: {} + # -- Provider-specific credentials map. Rendered under the matching + # YAML key based on managerType (e.g. auth0ClientCredentials, + # azureClientCredentials, keycloakClientCredentials, etc.). + providerConfig: {} + # ============================================================================= # ============================================================================= # PAT (Personal Access Token) Seeding diff --git a/ci/scripts/e2e-oidc.sh b/ci/scripts/e2e-oidc.sh new file mode 100755 index 0000000..1be0b32 --- /dev/null +++ b/ci/scripts/e2e-oidc.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# +# E2E test runner for the netbird Helm chart — OIDC integration. +# +# Usage: +# ci/scripts/e2e-oidc.sh +# +# Providers: +# keycloak — Keycloak deployed in-cluster (quay.io/keycloak/keycloak:26.0) +# +set -euo pipefail + +PROVIDER="${1:-keycloak}" +RELEASE="netbird-e2e" +NAMESPACE="netbird-e2e" +CHART="charts/netbird" +TIMEOUT="10m" + +log() { echo "==> $*"; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +# ── Cleanup function ─────────────────────────────────────────────────── +cleanup() { + log "Cleaning up..." + helm uninstall "$RELEASE" -n "$NAMESPACE" --ignore-not-found 2>/dev/null || true + kubectl delete namespace "$NAMESPACE" --ignore-not-found 2>/dev/null || true +} +trap cleanup EXIT + +# ── Create namespace ─────────────────────────────────────────────────── +log "Creating namespace $NAMESPACE..." +kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - + +# ── Deploy Keycloak ────────────────────────────────────────────────── +deploy_keycloak() { + log "Deploying Keycloak..." + kubectl -n "$NAMESPACE" apply -f - <<'EOF' +apiVersion: v1 +kind: Service +metadata: + name: keycloak +spec: + selector: + app: keycloak + ports: + - name: http + port: 8080 + targetPort: 8080 + - name: management + port: 9000 + targetPort: 9000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak +spec: + replicas: 1 + selector: + matchLabels: + app: keycloak + template: + metadata: + labels: + app: keycloak + spec: + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:26.0 + args: ["start-dev"] + env: + - name: KC_HEALTH_ENABLED + value: "true" + - name: KEYCLOAK_ADMIN + value: "admin" + - name: KEYCLOAK_ADMIN_PASSWORD + value: "admin" + ports: + - containerPort: 8080 + - containerPort: 9000 + readinessProbe: + httpGet: + path: /health/ready + port: 9000 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 20 +EOF + + log "Waiting for Keycloak to be ready..." + kubectl -n "$NAMESPACE" rollout status deployment/keycloak --timeout=300s +} + +# ── Configure Keycloak realm via REST API ──────────────────────────── +configure_keycloak() { + log "Configuring Keycloak realm and clients..." + + # Run configuration from a pod inside the cluster + kubectl -n "$NAMESPACE" run keycloak-setup --image=alpine:3.20 --restart=Never \ + --command -- sh -c ' + apk add --no-cache curl jq >/dev/null 2>&1 + + KC_URL="http://keycloak.netbird-e2e.svc.cluster.local:8080" + KC_MGMT_URL="http://keycloak.netbird-e2e.svc.cluster.local:9000" + + # Wait for Keycloak API (health endpoint is on management port 9000) + echo "Waiting for Keycloak API..." + for i in $(seq 1 60); do + if curl -sf "$KC_MGMT_URL/health/ready" >/dev/null 2>&1; then + echo "Keycloak API is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "FAIL: Keycloak not ready after 60 attempts" + exit 1 + fi + sleep 3 + done + + # Get admin token + echo "Getting admin token..." + ADMIN_TOKEN=$(curl -sf -X POST \ + "$KC_URL/realms/master/protocol/openid-connect/token" \ + -d "client_id=admin-cli" \ + -d "username=admin" \ + -d "password=admin" \ + -d "grant_type=password" | jq -r ".access_token") + + if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then + echo "FAIL: Could not get admin token" + exit 1 + fi + echo "Got admin token" + + # Create realm + echo "Creating netbird realm..." + curl -sf -X POST "$KC_URL/admin/realms" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"realm\":\"netbird\",\"enabled\":true}" || true + + # Create public client (for PKCE flow / direct grant testing) + echo "Creating netbird-client (public)..." + curl -sf -X POST "$KC_URL/admin/realms/netbird/clients" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\":\"netbird-client\", + \"publicClient\":true, + \"directAccessGrantsEnabled\":true, + \"redirectUris\":[\"*\"], + \"webOrigins\":[\"*\"], + \"protocol\":\"openid-connect\" + }" + + # Create confidential client (for IdP manager) + echo "Creating netbird-backend (confidential)..." + curl -sf -X POST "$KC_URL/admin/realms/netbird/clients" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\":\"netbird-backend\", + \"publicClient\":false, + \"serviceAccountsEnabled\":true, + \"secret\":\"test-backend-secret\", + \"directAccessGrantsEnabled\":false, + \"protocol\":\"openid-connect\" + }" + + # Create test user + echo "Creating test user..." + curl -sf -X POST "$KC_URL/admin/realms/netbird/users" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"username\":\"testuser\", + \"enabled\":true, + \"email\":\"test@netbird.test\", + \"firstName\":\"Test\", + \"lastName\":\"User\", + \"credentials\":[{\"type\":\"password\",\"value\":\"testpassword\",\"temporary\":false}] + }" + + echo "Keycloak configuration complete" + exit 0 + ' + + log "Waiting for Keycloak setup pod..." + kubectl -n "$NAMESPACE" wait --for=condition=Ready pod/keycloak-setup --timeout=60s 2>/dev/null || true + kubectl -n "$NAMESPACE" wait --for=jsonpath='{.status.phase}'=Succeeded pod/keycloak-setup --timeout=120s || { + log "Keycloak setup pod logs:" + kubectl -n "$NAMESPACE" logs keycloak-setup || true + fail "Keycloak configuration failed" + } + log "Keycloak setup pod logs:" + kubectl -n "$NAMESPACE" logs keycloak-setup || true + kubectl -n "$NAMESPACE" delete pod keycloak-setup --ignore-not-found 2>/dev/null || true +} + +# ── Create secrets ─────────────────────────────────────────────────── +create_oidc_secrets() { + log "Creating OIDC secrets..." + kubectl -n "$NAMESPACE" create secret generic netbird-idp-secret \ + --from-literal=clientSecret="test-backend-secret" +} + +# ── Provider dispatch ──────────────────────────────────────────────── +case "$PROVIDER" in + keycloak) + deploy_keycloak + configure_keycloak + create_oidc_secrets + VALUES_FILE="$CHART/ci/e2e-values-oidc-keycloak.yaml" + ;; + *) + fail "Unknown OIDC provider: $PROVIDER (expected: keycloak)" + ;; +esac + +# ── Install netbird chart ───────────────────────────────────────────── +log "Installing netbird chart with OIDC ($PROVIDER)..." +if ! helm install "$RELEASE" "$CHART" \ + -n "$NAMESPACE" \ + -f "$VALUES_FILE" \ + --timeout "$TIMEOUT"; then + log "Helm install failed — dumping logs..." + kubectl -n "$NAMESPACE" logs deployment/"$RELEASE"-server --all-containers --tail=100 2>/dev/null || true + fail "Helm install failed" +fi + +# ── Verify rollout ──────────────────────────────────────────────────── +log "Verifying deployments..." +kubectl -n "$NAMESPACE" rollout status deployment/"$RELEASE"-server --timeout=300s +kubectl -n "$NAMESPACE" rollout status deployment/"$RELEASE"-dashboard --timeout=120s + +log "Pod status:" +kubectl -n "$NAMESPACE" get pods -o wide + +# ── Run helm test ───────────────────────────────────────────────────── +log "Running helm test..." +helm test "$RELEASE" -n "$NAMESPACE" --timeout 2m + +# ── Verify OIDC middleware is active ───────────────────────────────── +# We verify the OIDC config was applied by checking that: +# 1. Unauthenticated requests return 401 (not 200 or 500) +# 2. Keycloak token acquisition works (realm + client configured) +# Full token-based auth testing is out of scope for the chart e2e: +# NetBird's embedded IdP layer re-signs tokens internally. +log "Verifying OIDC middleware is active..." +SVC_URL="http://$RELEASE-server.$NAMESPACE.svc.cluster.local:80" +KC_URL="http://keycloak.$NAMESPACE.svc.cluster.local:8080" + +kubectl -n "$NAMESPACE" run oidc-test --image=alpine:3.20 --restart=Never \ + --command -- sh -c " + apk add --no-cache curl jq >/dev/null 2>&1 + sleep 3 + + echo '==> Test 1: Unauthenticated request should return 401...' + HTTP_CODE=\$(curl -s -o /tmp/body -w '%{http_code}' '$SVC_URL/api/groups') + echo \"HTTP status: \$HTTP_CODE\" + echo \"Body: \$(cat /tmp/body | head -c 200)\" + if [ \"\$HTTP_CODE\" != '401' ]; then + echo \"FAIL: Expected HTTP 401, got \$HTTP_CODE\" + exit 1 + fi + echo 'PASS: Unauthenticated request returned 401 (OIDC middleware active)' + + echo '' + echo '==> Test 2: Obtain access token via Keycloak direct grant...' + TOKEN_RESPONSE=\$(curl -sf -X POST \ + '$KC_URL/realms/netbird/protocol/openid-connect/token' \ + -d 'client_id=netbird-client' \ + -d 'username=testuser' \ + -d 'password=testpassword' \ + -d 'grant_type=password' \ + -d 'scope=openid profile email') + + ACCESS_TOKEN=\$(echo \"\$TOKEN_RESPONSE\" | jq -r '.access_token') + if [ -z \"\$ACCESS_TOKEN\" ] || [ \"\$ACCESS_TOKEN\" = 'null' ]; then + echo 'FAIL: Could not obtain access token from Keycloak' + echo \"Token response: \$(echo \"\$TOKEN_RESPONSE\" | head -c 500)\" + exit 1 + fi + echo 'PASS: Keycloak token acquisition succeeded' + + echo '' + echo 'All OIDC e2e checks passed' + exit 0 + " + +log "Waiting for OIDC test pod..." +kubectl -n "$NAMESPACE" wait --for=condition=Ready pod/oidc-test --timeout=60s 2>/dev/null || true +kubectl -n "$NAMESPACE" wait --for=jsonpath='{.status.phase}'=Succeeded pod/oidc-test --timeout=120s || { + log "OIDC test pod logs:" + kubectl -n "$NAMESPACE" logs oidc-test || true + log "Server logs:" + kubectl -n "$NAMESPACE" logs deployment/"$RELEASE"-server --tail=50 || true + fail "OIDC authentication test failed" +} +log "OIDC test pod logs:" +kubectl -n "$NAMESPACE" logs oidc-test || true +log "E2E test with OIDC ($PROVIDER) PASSED!" From 4e391b4e1fba6d960f377316f2e2aec251904bf3 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Thu, 26 Feb 2026 12:57:08 +0100 Subject: [PATCH 2/2] feat(netbird): add Zitadel OIDC e2e test with PostgreSQL Add end-to-end test for Zitadel IdP integration deployed in-cluster alongside PostgreSQL. The test bootstraps Zitadel with a machine user and PAT, then uses the Management API to create a project, OIDC apps (Dashboard + CLI), a service user with client_credentials, and a test human user. Dynamic client IDs are substituted into the values file at runtime. Key implementation details: - Three-phase Zitadel deployment: init container (schema), setup init container (migrations + instance), main container (server) - Alpine sidecar for PAT file reading (Zitadel image is distroless) - emptyDir volume at /tmp for PAT output (distroless lacks writable /tmp) - enableServiceLinks: false to prevent K8s ZITADEL_PORT env var conflict - Cluster DNS as EXTERNALDOMAIN for Host header validation Tests verify: OIDC middleware returns 401, OIDC discovery works, and client_credentials token acquisition succeeds. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yaml | 37 + CHANGELOG.md | 3 + Makefile | 6 +- .../netbird/ci/e2e-values-oidc-zitadel.yaml | 100 +++ ci/scripts/e2e-oidc.sh | 633 ++++++++++++++++-- 5 files changed, 739 insertions(+), 40 deletions(-) create mode 100644 charts/netbird/ci/e2e-values-oidc-zitadel.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c6114ab..df3c2bc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -175,3 +175,40 @@ jobs: kubectl -n netbird-e2e logs deployment/keycloak --tail=100 || true echo "=== Events ===" kubectl -n netbird-e2e get events --sort-by='.lastTimestamp' || true + + e2e-oidc-zitadel: + name: E2E — OIDC (Zitadel) + runs-on: ubuntu-latest + needs: lint-and-unit-test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v4.0.2 + + - name: Create kind cluster + uses: helm/kind-action@v1 + with: + cluster_name: helms-e2e + + - name: Run e2e test (oidc-zitadel) + run: ci/scripts/e2e-oidc.sh zitadel + + - name: Show debug info on failure + if: failure() + run: | + echo "=== Pod status ===" + kubectl -n netbird-e2e get pods -o wide || true + echo "=== Server logs ===" + kubectl -n netbird-e2e logs deployment/netbird-e2e-server --all-containers --tail=100 || true + echo "=== Dashboard logs ===" + kubectl -n netbird-e2e logs deployment/netbird-e2e-dashboard --tail=100 || true + echo "=== Zitadel logs ===" + kubectl -n netbird-e2e logs deployment/zitadel --tail=100 || true + echo "=== PostgreSQL logs ===" + kubectl -n netbird-e2e logs deployment/zitadel-db --tail=50 || true + echo "=== Events ===" + kubectl -n netbird-e2e get events --sort-by='.lastTimestamp' || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c27cfc..171fade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). `dashboard.config.authAuthority` is empty. - E2E test with Keycloak deployed in-cluster: verifies OIDC middleware, token acquisition via direct grant, and authenticated API access. +- E2E test with Zitadel + PostgreSQL deployed in-cluster: bootstraps + project/apps/service user via Management API, verifies OIDC middleware, + OIDC discovery, and client_credentials token acquisition. - Unit tests for OIDC config rendering, secret injection, provider credentials key mapping, and dashboard fallback (190 tests total). diff --git a/Makefile b/Makefile index 28d1ef8..f324083 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint unittest e2e e2e-sqlite e2e-postgres e2e-mysql e2e-oidc-keycloak e2e-setup e2e-teardown test +.PHONY: lint unittest e2e e2e-sqlite e2e-postgres e2e-mysql e2e-oidc-keycloak e2e-oidc-zitadel e2e-setup e2e-teardown test CHARTS := $(wildcard charts/*) @@ -38,11 +38,15 @@ e2e-mysql: e2e-setup e2e-oidc-keycloak: e2e-setup ci/scripts/e2e-oidc.sh keycloak +e2e-oidc-zitadel: e2e-setup + ci/scripts/e2e-oidc.sh zitadel + e2e: e2e-setup ci/scripts/e2e.sh sqlite ci/scripts/e2e.sh postgres ci/scripts/e2e.sh mysql ci/scripts/e2e-oidc.sh keycloak + ci/scripts/e2e-oidc.sh zitadel e2e-teardown: kind delete cluster --name $(E2E_CLUSTER) 2>/dev/null || true diff --git a/charts/netbird/ci/e2e-values-oidc-zitadel.yaml b/charts/netbird/ci/e2e-values-oidc-zitadel.yaml new file mode 100644 index 0000000..8fcf9d1 --- /dev/null +++ b/charts/netbird/ci/e2e-values-oidc-zitadel.yaml @@ -0,0 +1,100 @@ +# E2E test values — OIDC with Zitadel deployed in-cluster. +# +# Zitadel is deployed alongside a PostgreSQL pod in the same namespace. +# The test bootstraps Zitadel with a machine user + PAT, then uses the +# Management API to create: +# - A project "NETBIRD" +# - A "Dashboard" OIDC app (public, user-agent type) +# - A "CLI" OIDC app (native, device code grant) +# - A service user with client_credentials for IdP management +# - A test human user for token acquisition +# +# Dynamic values (client IDs, service user credentials) are substituted +# at runtime by the e2e script before helm install. + +database: + type: sqlite + +server: + persistentVolume: + enabled: false + + stunService: + type: ClusterIP + + config: + exposedAddress: "https://netbird.localhost" + auth: + issuer: "http://zitadel.netbird-e2e.svc.cluster.local:8080" + dashboardRedirectURIs: + - "https://netbird.localhost/nb-auth" + - "https://netbird.localhost/nb-silent-auth" + + livenessProbe: + failureThreshold: 20 + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 3 + tcpSocket: + port: http + readinessProbe: + failureThreshold: 20 + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 3 + tcpSocket: + port: http + +oidc: + enabled: true + audience: "PLACEHOLDER_PROJECT_ID" + userIdClaim: "sub" + configEndpoint: "http://zitadel.netbird-e2e.svc.cluster.local:8080/.well-known/openid-configuration" + + deviceAuthFlow: + enabled: true + provider: "hosted" + providerConfig: + clientId: "PLACEHOLDER_CLI_CLIENT_ID" + audience: "PLACEHOLDER_PROJECT_ID" + tokenEndpoint: "http://zitadel.netbird-e2e.svc.cluster.local:8080/oauth/v2/token" + deviceAuthEndpoint: "http://zitadel.netbird-e2e.svc.cluster.local:8080/oauth/v2/device_authorization" + scope: "openid profile email" + useIdToken: false + + pkceAuthFlow: + enabled: true + providerConfig: + clientId: "PLACEHOLDER_DASHBOARD_CLIENT_ID" + audience: "PLACEHOLDER_PROJECT_ID" + clientSecret: + value: "" + tokenEndpoint: "http://zitadel.netbird-e2e.svc.cluster.local:8080/oauth/v2/token" + authorizationEndpoint: "http://zitadel.netbird-e2e.svc.cluster.local:8080/oauth/v2/authorize" + scope: "openid profile email" + redirectUrls: + - "https://netbird.localhost/nb-auth" + - "https://netbird.localhost/nb-silent-auth" + useIdToken: false + + idpManager: + enabled: true + managerType: "zitadel" + clientConfig: + issuer: "http://zitadel.netbird-e2e.svc.cluster.local:8080" + tokenEndpoint: "http://zitadel.netbird-e2e.svc.cluster.local:8080/oauth/v2/token" + clientId: "PLACEHOLDER_SVC_CLIENT_ID" + clientSecret: + secretName: netbird-zitadel-idp-secret + secretKey: clientSecret + grantType: "client_credentials" + extraConfig: + ManagementEndpoint: "http://zitadel.netbird-e2e.svc.cluster.local:8080/management/v1" + +dashboard: + config: + mgmtApiEndpoint: "https://netbird.localhost" + mgmtGrpcApiEndpoint: "https://netbird.localhost" + authAuthority: "http://zitadel.netbird-e2e.svc.cluster.local:8080" + authClientId: "PLACEHOLDER_DASHBOARD_CLIENT_ID" + authAudience: "PLACEHOLDER_PROJECT_ID" diff --git a/ci/scripts/e2e-oidc.sh b/ci/scripts/e2e-oidc.sh index 1be0b32..7d2f2fe 100755 --- a/ci/scripts/e2e-oidc.sh +++ b/ci/scripts/e2e-oidc.sh @@ -7,6 +7,7 @@ # # Providers: # keycloak — Keycloak deployed in-cluster (quay.io/keycloak/keycloak:26.0) +# zitadel — Zitadel + PostgreSQL deployed in-cluster (ghcr.io/zitadel/zitadel:v2.71.6) # set -euo pipefail @@ -205,6 +206,485 @@ create_oidc_secrets() { --from-literal=clientSecret="test-backend-secret" } +# ── Deploy Zitadel + PostgreSQL ───────────────────────────────────── +# Architecture: PostgreSQL → Zitadel (init containers: init + setup, +# main container: start, Alpine sidecar for PAT reading). +# The Zitadel image is distroless — it has no shell, no cat — so a +# sidecar is needed to read files from the shared /tmp volume. +ZITADEL_DB_ENVS=' + - name: ZITADEL_DATABASE_POSTGRES_HOST + value: "zitadel-db" + - name: ZITADEL_DATABASE_POSTGRES_PORT + value: "5432" + - name: ZITADEL_DATABASE_POSTGRES_DATABASE + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_USER_USERNAME + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_USER_PASSWORD + value: "zitadel-test-pw" + - name: ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE + value: "disable" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD + value: "zitadel-test-pw" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE + value: "disable"' + +deploy_zitadel() { + log "Deploying PostgreSQL for Zitadel..." + kubectl -n "$NAMESPACE" apply -f - <<'EOF' +apiVersion: v1 +kind: Service +metadata: + name: zitadel-db +spec: + selector: + app: zitadel-db + ports: + - port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: zitadel-db +spec: + replicas: 1 + selector: + matchLabels: + app: zitadel-db + template: + metadata: + labels: + app: zitadel-db + spec: + containers: + - name: postgres + image: postgres:16-alpine + env: + - name: POSTGRES_DB + value: "zitadel" + - name: POSTGRES_USER + value: "zitadel" + - name: POSTGRES_PASSWORD + value: "zitadel-test-pw" + ports: + - containerPort: 5432 + readinessProbe: + exec: + command: ["pg_isready", "-U", "zitadel"] + initialDelaySeconds: 5 + periodSeconds: 3 + failureThreshold: 10 +EOF + + log "Waiting for PostgreSQL to be ready..." + kubectl -n "$NAMESPACE" rollout status deployment/zitadel-db --timeout=120s + + log "Deploying Zitadel (init → setup → start)..." + kubectl -n "$NAMESPACE" apply -f - <<'EOF' +apiVersion: v1 +kind: Service +metadata: + name: zitadel +spec: + selector: + app: zitadel + ports: + - name: http + port: 8080 + targetPort: 8080 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: zitadel +spec: + replicas: 1 + selector: + matchLabels: + app: zitadel + template: + metadata: + labels: + app: zitadel + spec: + # Disable Kubernetes service link env vars to prevent ZITADEL_PORT + # being set to "tcp://10.x.x.x:8080" (Zitadel reads ZITADEL_PORT + # as its config Port field and fails to parse it as a uint). + enableServiceLinks: false + initContainers: + # Phase 1: init — creates database schema (idempotent) + - name: zitadel-init + image: ghcr.io/zitadel/zitadel:v2.71.6 + command: ["/app/zitadel"] + args: ["init"] + env: + - name: ZITADEL_DATABASE_POSTGRES_HOST + value: "zitadel-db" + - name: ZITADEL_DATABASE_POSTGRES_PORT + value: "5432" + - name: ZITADEL_DATABASE_POSTGRES_DATABASE + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_USER_USERNAME + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_USER_PASSWORD + value: "zitadel-test-pw" + - name: ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE + value: "disable" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD + value: "zitadel-test-pw" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE + value: "disable" + # Phase 2: setup — runs migrations, creates default instance + machine user + PAT + - name: zitadel-setup + image: ghcr.io/zitadel/zitadel:v2.71.6 + command: ["/app/zitadel"] + args: + - setup + - --masterkey + - "x123456789012345678901234567891y" + - --tlsMode + - disabled + env: + - name: ZITADEL_EXTERNALDOMAIN + value: "zitadel.netbird-e2e.svc.cluster.local" + - name: ZITADEL_EXTERNALPORT + value: "8080" + - name: ZITADEL_EXTERNALSECURE + value: "false" + - name: ZITADEL_DATABASE_POSTGRES_HOST + value: "zitadel-db" + - name: ZITADEL_DATABASE_POSTGRES_PORT + value: "5432" + - name: ZITADEL_DATABASE_POSTGRES_DATABASE + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_USER_USERNAME + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_USER_PASSWORD + value: "zitadel-test-pw" + - name: ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE + value: "disable" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD + value: "zitadel-test-pw" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE + value: "disable" + - name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME + value: "zitadel-admin@zitadel.localhost" + - name: ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD + value: "Password1!" + - name: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME + value: "bootstrap-sa" + - name: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME + value: "Bootstrap Service Account" + - name: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINEKEY_TYPE + value: "1" + - name: ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE + value: "2030-01-01T00:00:00Z" + - name: ZITADEL_FIRSTINSTANCE_PATPATH + value: "/tmp/zitadel-pat" + volumeMounts: + - name: zitadel-tmp + mountPath: /tmp + containers: + # Main container: Zitadel server + - name: zitadel + image: ghcr.io/zitadel/zitadel:v2.71.6 + command: ["/app/zitadel"] + args: + - start + - --masterkey + - "x123456789012345678901234567891y" + - --tlsMode + - disabled + env: + - name: ZITADEL_EXTERNALDOMAIN + value: "zitadel.netbird-e2e.svc.cluster.local" + - name: ZITADEL_EXTERNALPORT + value: "8080" + - name: ZITADEL_EXTERNALSECURE + value: "false" + - name: ZITADEL_DATABASE_POSTGRES_HOST + value: "zitadel-db" + - name: ZITADEL_DATABASE_POSTGRES_PORT + value: "5432" + - name: ZITADEL_DATABASE_POSTGRES_DATABASE + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_USER_USERNAME + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_USER_PASSWORD + value: "zitadel-test-pw" + - name: ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE + value: "disable" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME + value: "zitadel" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD + value: "zitadel-test-pw" + - name: ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE + value: "disable" + ports: + - containerPort: 8080 + readinessProbe: + httpGet: + path: /debug/ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 20 + volumeMounts: + - name: zitadel-tmp + mountPath: /tmp + # Sidecar: Alpine shell for reading PAT from shared /tmp + # (Zitadel image is distroless — no shell, no cat) + - name: pat-reader + image: alpine:3.20 + command: ["sh", "-c", "while true; do sleep 3600; done"] + volumeMounts: + - name: zitadel-tmp + mountPath: /tmp + volumes: + - name: zitadel-tmp + emptyDir: {} +EOF + + log "Waiting for Zitadel to be ready (this may take 1-2 minutes)..." + kubectl -n "$NAMESPACE" rollout status deployment/zitadel --timeout=600s +} + +# ── Configure Zitadel project, apps, and users via REST API ──────── +# Outputs: writes a resolved values file to $ZITADEL_VALUES_FILE +configure_zitadel() { + log "Configuring Zitadel project and clients..." + + # Read the bootstrap PAT from the sidecar container + ZITADEL_POD=$(kubectl -n "$NAMESPACE" get pod -l app=zitadel -o jsonpath='{.items[0].metadata.name}') + log "Reading bootstrap PAT from pod $ZITADEL_POD (pat-reader sidecar)..." + BOOTSTRAP_PAT="" + for attempt in $(seq 1 30); do + BOOTSTRAP_PAT=$(kubectl -n "$NAMESPACE" exec "$ZITADEL_POD" -c pat-reader -- cat /tmp/zitadel-pat 2>/dev/null || true) + if [ -n "$BOOTSTRAP_PAT" ]; then + break + fi + sleep 2 + done + if [ -z "$BOOTSTRAP_PAT" ]; then + fail "Could not read bootstrap PAT from Zitadel pod" + fi + log "Got bootstrap PAT" + + # Run setup from a pod inside the cluster (for DNS resolution) + kubectl -n "$NAMESPACE" run zitadel-setup --image=alpine:3.20 --restart=Never \ + --env="BOOTSTRAP_PAT=$BOOTSTRAP_PAT" \ + --command -- sh -c ' + apk add --no-cache curl jq >/dev/null 2>&1 + + ZT_URL="http://zitadel.netbird-e2e.svc.cluster.local:8080" + PAT="$BOOTSTRAP_PAT" + AUTH="Authorization: Bearer $PAT" + + # Wait for Zitadel API + echo "Waiting for Zitadel API..." + for i in $(seq 1 60); do + if curl -sf "$ZT_URL/debug/ready" >/dev/null 2>&1; then + echo "Zitadel API is ready" + break + fi + if [ "$i" -eq 60 ]; then + echo "FAIL: Zitadel not ready after 60 attempts" + exit 1 + fi + sleep 3 + done + + # Grant the bootstrap SA IAM_ORG_MANAGER role so it can create users + echo "Granting bootstrap SA org owner role..." + # Get the bootstrap SA user ID + BOOTSTRAP_USER=$(curl -sS "$ZT_URL/management/v1/users/me" \ + -H "$AUTH" -H "Content-Type: application/json" | jq -r ".user.id") + echo "Bootstrap user ID: $BOOTSTRAP_USER" + + # 1. Create project + echo "Creating NETBIRD project..." + PROJECT_RESP=$(curl -sS -X POST "$ZT_URL/management/v1/projects" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{\"name\":\"NETBIRD\",\"projectRoleAssertion\":true}") + PROJECT_ID=$(echo "$PROJECT_RESP" | jq -r ".id") + if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then + echo "FAIL: Could not create project" + echo "$PROJECT_RESP" + exit 1 + fi + echo "Project ID: $PROJECT_ID" + + # 2. Create Dashboard OIDC app (public, user-agent type) + echo "Creating Dashboard OIDC app..." + DASH_RESP=$(curl -sS -X POST "$ZT_URL/management/v1/projects/$PROJECT_ID/apps/oidc" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{ + \"name\":\"Dashboard\", + \"redirectUris\":[\"https://netbird.localhost/nb-auth\",\"https://netbird.localhost/nb-silent-auth\"], + \"postLogoutRedirectUris\":[\"https://netbird.localhost/\"], + \"responseTypes\":[\"OIDC_RESPONSE_TYPE_CODE\"], + \"grantTypes\":[\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\",\"OIDC_GRANT_TYPE_REFRESH_TOKEN\"], + \"appType\":\"OIDC_APP_TYPE_USER_AGENT\", + \"authMethodType\":\"OIDC_AUTH_METHOD_TYPE_NONE\", + \"devMode\":true, + \"accessTokenType\":\"OIDC_TOKEN_TYPE_JWT\", + \"accessTokenRoleAssertion\":true, + \"idTokenRoleAssertion\":true, + \"idTokenUserinfoAssertion\":true + }") + DASHBOARD_CLIENT_ID=$(echo "$DASH_RESP" | jq -r ".clientId") + if [ -z "$DASHBOARD_CLIENT_ID" ] || [ "$DASHBOARD_CLIENT_ID" = "null" ]; then + echo "FAIL: Could not create Dashboard app" + echo "$DASH_RESP" + exit 1 + fi + echo "Dashboard client ID: $DASHBOARD_CLIENT_ID" + + # 3. Create CLI OIDC app (native, device code grant) + echo "Creating CLI OIDC app..." + CLI_RESP=$(curl -sS -X POST "$ZT_URL/management/v1/projects/$PROJECT_ID/apps/oidc" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{ + \"name\":\"CLI\", + \"redirectUris\":[\"http://localhost:53000/\",\"http://localhost:54000/\"], + \"postLogoutRedirectUris\":[\"http://localhost:53000/\"], + \"responseTypes\":[\"OIDC_RESPONSE_TYPE_CODE\"], + \"grantTypes\":[\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\",\"OIDC_GRANT_TYPE_DEVICE_CODE\",\"OIDC_GRANT_TYPE_REFRESH_TOKEN\"], + \"appType\":\"OIDC_APP_TYPE_NATIVE\", + \"authMethodType\":\"OIDC_AUTH_METHOD_TYPE_NONE\", + \"devMode\":true, + \"accessTokenType\":\"OIDC_TOKEN_TYPE_JWT\", + \"accessTokenRoleAssertion\":true, + \"idTokenRoleAssertion\":true, + \"idTokenUserinfoAssertion\":true + }") + CLI_CLIENT_ID=$(echo "$CLI_RESP" | jq -r ".clientId") + if [ -z "$CLI_CLIENT_ID" ] || [ "$CLI_CLIENT_ID" = "null" ]; then + echo "FAIL: Could not create CLI app" + echo "$CLI_RESP" + exit 1 + fi + echo "CLI client ID: $CLI_CLIENT_ID" + + # 4. Create a service user for IdP management + echo "Creating service user for IdP management..." + SVC_RESP=$(curl -sS -X POST "$ZT_URL/management/v1/users/machine" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{ + \"userName\":\"netbird-service-account\", + \"name\":\"NetBird Service Account\", + \"description\":\"NetBird IdP manager service account\", + \"accessTokenType\":\"ACCESS_TOKEN_TYPE_JWT\" + }") + SVC_USER_ID=$(echo "$SVC_RESP" | jq -r ".userId") + if [ -z "$SVC_USER_ID" ] || [ "$SVC_USER_ID" = "null" ]; then + echo "FAIL: Could not create service user" + echo "$SVC_RESP" + exit 1 + fi + echo "Service user ID: $SVC_USER_ID" + + # Generate client secret for the service user + echo "Generating client secret for service user..." + SECRET_RESP=$(curl -sS -X PUT "$ZT_URL/management/v1/users/$SVC_USER_ID/secret" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{}") + SVC_CLIENT_ID=$(echo "$SECRET_RESP" | jq -r ".clientId") + SVC_CLIENT_SECRET=$(echo "$SECRET_RESP" | jq -r ".clientSecret") + if [ -z "$SVC_CLIENT_ID" ] || [ "$SVC_CLIENT_ID" = "null" ]; then + echo "FAIL: Could not generate client secret" + echo "$SECRET_RESP" + exit 1 + fi + echo "Service user client ID: $SVC_CLIENT_ID" + + # Grant Org User Manager role to the service user + echo "Granting ORG_USER_MANAGER role to service user..." + curl -sS -X POST "$ZT_URL/management/v1/orgs/me/members" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{\"userId\":\"$SVC_USER_ID\",\"roles\":[\"ORG_USER_MANAGER\"]}" >/dev/null + + # 5. Create test human user + echo "Creating test human user..." + curl -sS -X POST "$ZT_URL/v2/users/human" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{ + \"username\":\"testuser\", + \"profile\":{\"givenName\":\"Test\",\"familyName\":\"User\",\"displayName\":\"Test User\"}, + \"email\":{\"email\":\"test@netbird.test\",\"isVerified\":true}, + \"password\":{\"password\":\"TestPassword1!\",\"changeRequired\":false} + }" >/dev/null + + # Output results as a simple key=value format for the caller to parse + echo "" + echo "ZITADEL_SETUP_RESULTS_START" + echo "PROJECT_ID=$PROJECT_ID" + echo "DASHBOARD_CLIENT_ID=$DASHBOARD_CLIENT_ID" + echo "CLI_CLIENT_ID=$CLI_CLIENT_ID" + echo "SVC_CLIENT_ID=$SVC_CLIENT_ID" + echo "SVC_CLIENT_SECRET=$SVC_CLIENT_SECRET" + echo "ZITADEL_SETUP_RESULTS_END" + + echo "" + echo "Zitadel configuration complete" + exit 0 + ' + + log "Waiting for Zitadel setup pod..." + kubectl -n "$NAMESPACE" wait --for=condition=Ready pod/zitadel-setup --timeout=60s 2>/dev/null || true + kubectl -n "$NAMESPACE" wait --for=jsonpath='{.status.phase}'=Succeeded pod/zitadel-setup --timeout=180s || { + log "Zitadel setup pod logs:" + kubectl -n "$NAMESPACE" logs zitadel-setup || true + fail "Zitadel configuration failed" + } + log "Zitadel setup pod logs:" + SETUP_LOGS=$(kubectl -n "$NAMESPACE" logs zitadel-setup) + echo "$SETUP_LOGS" + + # Parse the setup results + PROJECT_ID=$(echo "$SETUP_LOGS" | sed -n 's/^PROJECT_ID=//p') + DASHBOARD_CLIENT_ID=$(echo "$SETUP_LOGS" | sed -n 's/^DASHBOARD_CLIENT_ID=//p') + CLI_CLIENT_ID=$(echo "$SETUP_LOGS" | sed -n 's/^CLI_CLIENT_ID=//p') + SVC_CLIENT_ID=$(echo "$SETUP_LOGS" | sed -n 's/^SVC_CLIENT_ID=//p') + SVC_CLIENT_SECRET=$(echo "$SETUP_LOGS" | sed -n 's/^SVC_CLIENT_SECRET=//p') + + if [ -z "$PROJECT_ID" ] || [ -z "$DASHBOARD_CLIENT_ID" ] || [ -z "$CLI_CLIENT_ID" ] || [ -z "$SVC_CLIENT_ID" ] || [ -z "$SVC_CLIENT_SECRET" ]; then + fail "Could not parse Zitadel setup results" + fi + + log "Zitadel setup results:" + log " Project ID: $PROJECT_ID" + log " Dashboard client ID: $DASHBOARD_CLIENT_ID" + log " CLI client ID: $CLI_CLIENT_ID" + log " Service client ID: $SVC_CLIENT_ID" + + kubectl -n "$NAMESPACE" delete pod zitadel-setup --ignore-not-found 2>/dev/null || true + + # Create the K8s secret for IdP manager client credentials + log "Creating Zitadel IdP secret..." + kubectl -n "$NAMESPACE" create secret generic netbird-zitadel-idp-secret \ + --from-literal=clientSecret="$SVC_CLIENT_SECRET" + + # Generate the resolved values file by substituting placeholders + ZITADEL_VALUES_FILE=$(mktemp) + sed \ + -e "s/PLACEHOLDER_PROJECT_ID/$PROJECT_ID/g" \ + -e "s/PLACEHOLDER_DASHBOARD_CLIENT_ID/$DASHBOARD_CLIENT_ID/g" \ + -e "s/PLACEHOLDER_CLI_CLIENT_ID/$CLI_CLIENT_ID/g" \ + -e "s/PLACEHOLDER_SVC_CLIENT_ID/$SVC_CLIENT_ID/g" \ + "$CHART/ci/e2e-values-oidc-zitadel.yaml" > "$ZITADEL_VALUES_FILE" + + log "Resolved values written to $ZITADEL_VALUES_FILE" +} + # ── Provider dispatch ──────────────────────────────────────────────── case "$PROVIDER" in keycloak) @@ -213,8 +693,13 @@ case "$PROVIDER" in create_oidc_secrets VALUES_FILE="$CHART/ci/e2e-values-oidc-keycloak.yaml" ;; + zitadel) + deploy_zitadel + configure_zitadel + VALUES_FILE="$ZITADEL_VALUES_FILE" + ;; *) - fail "Unknown OIDC provider: $PROVIDER (expected: keycloak)" + fail "Unknown OIDC provider: $PROVIDER (expected: keycloak, zitadel)" ;; esac @@ -244,50 +729,120 @@ helm test "$RELEASE" -n "$NAMESPACE" --timeout 2m # ── Verify OIDC middleware is active ───────────────────────────────── # We verify the OIDC config was applied by checking that: # 1. Unauthenticated requests return 401 (not 200 or 500) -# 2. Keycloak token acquisition works (realm + client configured) +# 2. Token acquisition works against the IdP (realm/project configured) # Full token-based auth testing is out of scope for the chart e2e: # NetBird's embedded IdP layer re-signs tokens internally. log "Verifying OIDC middleware is active..." SVC_URL="http://$RELEASE-server.$NAMESPACE.svc.cluster.local:80" -KC_URL="http://keycloak.$NAMESPACE.svc.cluster.local:8080" -kubectl -n "$NAMESPACE" run oidc-test --image=alpine:3.20 --restart=Never \ - --command -- sh -c " - apk add --no-cache curl jq >/dev/null 2>&1 - sleep 3 - - echo '==> Test 1: Unauthenticated request should return 401...' - HTTP_CODE=\$(curl -s -o /tmp/body -w '%{http_code}' '$SVC_URL/api/groups') - echo \"HTTP status: \$HTTP_CODE\" - echo \"Body: \$(cat /tmp/body | head -c 200)\" - if [ \"\$HTTP_CODE\" != '401' ]; then - echo \"FAIL: Expected HTTP 401, got \$HTTP_CODE\" - exit 1 - fi - echo 'PASS: Unauthenticated request returned 401 (OIDC middleware active)' - - echo '' - echo '==> Test 2: Obtain access token via Keycloak direct grant...' - TOKEN_RESPONSE=\$(curl -sf -X POST \ - '$KC_URL/realms/netbird/protocol/openid-connect/token' \ - -d 'client_id=netbird-client' \ - -d 'username=testuser' \ - -d 'password=testpassword' \ - -d 'grant_type=password' \ - -d 'scope=openid profile email') - - ACCESS_TOKEN=\$(echo \"\$TOKEN_RESPONSE\" | jq -r '.access_token') - if [ -z \"\$ACCESS_TOKEN\" ] || [ \"\$ACCESS_TOKEN\" = 'null' ]; then - echo 'FAIL: Could not obtain access token from Keycloak' - echo \"Token response: \$(echo \"\$TOKEN_RESPONSE\" | head -c 500)\" - exit 1 - fi - echo 'PASS: Keycloak token acquisition succeeded' +# Write the test script into a ConfigMap to avoid escaping issues +OIDC_TEST_SCRIPT=$(mktemp) +cat > "$OIDC_TEST_SCRIPT" <<'TESTEOF' +#!/bin/sh +set -e +apk add --no-cache curl jq >/dev/null 2>&1 +sleep 3 - echo '' - echo 'All OIDC e2e checks passed' - exit 0 - " +echo "==> Test 1: Unauthenticated request should return 401..." +HTTP_CODE=$(curl -s -o /tmp/body -w '%{http_code}' "$SVC_URL/api/groups") +echo "HTTP status: $HTTP_CODE" +echo "Body: $(cat /tmp/body | head -c 200)" +if [ "$HTTP_CODE" != "401" ]; then + echo "FAIL: Expected HTTP 401, got $HTTP_CODE" + exit 1 +fi +echo "PASS: Unauthenticated request returned 401 (OIDC middleware active)" + +echo "" +if [ "$PROVIDER" = "keycloak" ]; then + echo "==> Test 2: Obtain access token via Keycloak direct grant..." + TOKEN_RESPONSE=$(curl -sf -X POST \ + "$IDP_URL/realms/netbird/protocol/openid-connect/token" \ + -d "client_id=netbird-client" \ + -d "username=testuser" \ + -d "password=testpassword" \ + -d "grant_type=password" \ + -d "scope=openid profile email") + ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token') + if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then + echo "FAIL: Could not obtain access token from Keycloak" + echo "Token response: $(echo "$TOKEN_RESPONSE" | head -c 500)" + exit 1 + fi + echo "PASS: Keycloak token acquisition succeeded" + +elif [ "$PROVIDER" = "zitadel" ]; then + echo "==> Test 2: Verify Zitadel OIDC discovery endpoint..." + OIDC_CONFIG=$(curl -sf "$IDP_URL/.well-known/openid-configuration") + TOKEN_EP=$(echo "$OIDC_CONFIG" | jq -r '.token_endpoint') + if [ -z "$TOKEN_EP" ] || [ "$TOKEN_EP" = "null" ]; then + echo "FAIL: Could not discover token_endpoint from Zitadel OIDC config" + echo "OIDC config: $(echo "$OIDC_CONFIG" | head -c 500)" + exit 1 + fi + echo "PASS: Zitadel OIDC discovery returned token_endpoint: $TOKEN_EP" + + echo "" + echo "==> Test 3: Obtain client_credentials token from Zitadel..." + TOKEN_RESPONSE=$(curl -sf -X POST "$IDP_URL/oauth/v2/token" \ + -u "$SVC_CLIENT_ID:$SVC_CLIENT_SECRET" \ + -d "grant_type=client_credentials" \ + -d "scope=openid profile urn:zitadel:iam:org:project:id:zitadel:aud") + ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token') + if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then + echo "FAIL: Could not obtain client_credentials token from Zitadel" + echo "Token response: $(echo "$TOKEN_RESPONSE" | head -c 500)" + exit 1 + fi + echo "PASS: Zitadel client_credentials token acquisition succeeded" +fi + +echo "" +echo "All OIDC e2e checks passed" +exit 0 +TESTEOF + +# Create ConfigMap from the test script +kubectl -n "$NAMESPACE" create configmap oidc-test-script \ + --from-file=test.sh="$OIDC_TEST_SCRIPT" +rm -f "$OIDC_TEST_SCRIPT" + +# Determine IdP URL and extra env vars per provider +case "$PROVIDER" in + keycloak) IDP_URL="http://keycloak.$NAMESPACE.svc.cluster.local:8080" ;; + zitadel) IDP_URL="http://zitadel.$NAMESPACE.svc.cluster.local:8080" ;; +esac + +kubectl -n "$NAMESPACE" apply -f - </dev/null || true