diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7b7e31ef6..2969cb459 100755 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -242,10 +242,80 @@ jobs: echo "Checking services..." kubectl get svc -n ambient-code - - name: Run Cypress E2E tests + - name: Run Cypress E2E tests (legacy auth) working-directory: e2e run: ./scripts/run-tests.sh + - name: Toggle SSO auth mode + run: | + # Verify Keycloak is healthy before toggling (backend needs it for OIDC discovery) + echo "Verifying Keycloak is ready..." + kubectl wait --for=condition=available --timeout=60s deployment/keycloak -n ambient-code + + # Enable SSO on frontend + kubectl set env deployment/frontend -n ambient-code SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true + + # Enable SSO feature flag in Unleash + UNLEASH_ADMIN_TOKEN=$(kubectl get secret unleash-credentials -n ambient-code -o jsonpath='{.data.admin-api-token}' | base64 -d) + kubectl port-forward -n ambient-code svc/unleash 4242:4242 & + PF=$! + sleep 3 + # Create flag if it doesn't exist, then enable + curl -sf -X POST "http://localhost:4242/api/admin/projects/default/features" \ + -H "Authorization: $UNLEASH_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"sso-authentication","type":"release"}' 2>/dev/null || true + curl -sf -X POST "http://localhost:4242/api/admin/projects/default/features/sso-authentication/environments/development/strategies" \ + -H "Authorization: $UNLEASH_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"default","parameters":{}}' 2>/dev/null || true + curl -sf -X POST "http://localhost:4242/api/admin/projects/default/features/sso-authentication/environments/development/on" \ + -H "Authorization: $UNLEASH_ADMIN_TOKEN" 2>/dev/null || true + kill $PF 2>/dev/null || true + + # Wait for frontend rollout + kubectl rollout status deployment/frontend -n ambient-code --timeout=60s + + # Restart backend to pick up flag change faster + kubectl rollout restart deployment/backend-api -n ambient-code + kubectl rollout status deployment/backend-api -n ambient-code --timeout=60s + + # Verify backend JWT validator initialized (OIDC discovery reached Keycloak) + echo "Verifying backend SSO initialization..." + for i in $(seq 1 15); do + if kubectl logs -n ambient-code -l app=backend-api --tail=50 2>/dev/null | grep -q "SSO: JWT validator initialized"; then + echo "Backend JWT validator initialized" + break + fi + if [ "$i" -eq 15 ]; then + echo "WARNING: Backend JWT validator may not have initialized" + kubectl logs -n ambient-code -l app=backend-api --tail=20 | grep -i sso || true + fi + sleep 2 + done + + - name: Run Cypress E2E tests (SSO auth) + working-directory: e2e + env: + E2E_USE_SSO: "true" + run: | + # Re-extract token using Keycloak client_credentials + ./scripts/extract-token.sh + # Verify frontend is healthy after SSO toggle before running tests + echo "Waiting for frontend to be ready with SSO..." + for i in $(seq 1 30); do + if curl -sf -o /dev/null http://localhost/api/version 2>/dev/null; then + echo "Frontend ready" + break + fi + if [ "$i" -eq 30 ]; then + echo "Frontend not ready after 60s" + kubectl logs -n ambient-code -l app=frontend --tail=20 || true + exit 1 + fi + sleep 2 + done + ./scripts/run-tests.sh - name: Upload test results if: failure() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8fe50efc..0a3d673b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -395,7 +395,8 @@ make kind-up This command will: - Create Kind cluster (~30 seconds) - Deploy all components (backend, frontend, operator) -- Set up ingress and port forwarding +- Deploy Keycloak with a pre-configured dev realm +- Set up port forwarding - Load container images The setup takes ~2 minutes on first run. @@ -403,10 +404,11 @@ The setup takes ~2 minutes on first run. #### Access the Application ```bash -# Access at http://localhost:8080 +make kind-port-forward # In another terminal +# Open the frontend URL shown in the output ``` -Simple! Kind automatically sets up port forwarding to localhost. +You'll be redirected to Keycloak for login. Use `developer` / `developer`. #### Stopping and Restarting diff --git a/Makefile b/Makefile index 48d6f045b..f7ca4c1a4 100755 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ .PHONY: local-dev-token .PHONY: local-logs local-logs-backend local-logs-frontend local-logs-operator local-shell local-shell-frontend .PHONY: local-test local-test-dev local-test-quick test-all local-troubleshoot local-port-forward local-stop-port-forward -.PHONY: push-all registry-login setup-hooks remove-hooks lint check-minikube check-kind check-kubectl check-local-context dev-bootstrap kind-rebuild kind-reload-backend kind-reload-frontend kind-reload-operator kind-status kind-login +.PHONY: push-all registry-login setup-hooks remove-hooks lint check-minikube check-kind check-kubectl check-local-context dev-bootstrap kind-rebuild kind-reload-backend kind-reload-frontend kind-reload-operator kind-status kind-login kind-sso-toggle .PHONY: preflight-cluster preflight dev-env dev .PHONY: e2e-test e2e-setup e2e-clean deploy-langfuse-openshift .PHONY: unleash-port-forward unleash-status @@ -1065,6 +1065,42 @@ kind-reload-operator: check-kind check-kubectl check-local-context ## Rebuild an @kubectl rollout status deployment/agentic-operator -n $(NAMESPACE) --timeout=60s @echo "$(COLOR_GREEN)✓$(COLOR_RESET) Operator reloaded" +kind-sso-toggle: check-kubectl ## Toggle SSO auth on/off in Kind (affects both frontend and backend) + @UNLEASH_ADMIN_TOKEN=$$(kubectl get secret unleash-credentials -n $(NAMESPACE) -o jsonpath='{.data.admin-api-token}' | base64 -d); \ + CURRENT=$$(kubectl get deployment frontend -n $(NAMESPACE) -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="SSO_ENABLED")].value}' 2>/dev/null); \ + if [ "$$CURRENT" = "true" ]; then \ + echo "$(COLOR_BLUE)▶$(COLOR_RESET) Disabling SSO auth (switching to legacy mode)..."; \ + kubectl set env deployment/frontend -n $(NAMESPACE) SSO_ENABLED=false NEXT_PUBLIC_SSO_ENABLED=false; \ + kubectl port-forward -n $(NAMESPACE) svc/unleash 4242:4242 >/dev/null 2>&1 & PF=$$!; sleep 2; \ + curl -sf -X POST "http://localhost:4242/api/admin/projects/default/features/sso-authentication/environments/development/off" \ + -H "Authorization: $$UNLEASH_ADMIN_TOKEN" >/dev/null 2>&1 || true; \ + kill $$PF 2>/dev/null; \ + echo "$(COLOR_GREEN)✓$(COLOR_RESET) SSO disabled. Frontend will use OC_TOKEN/OAuth proxy headers."; \ + else \ + echo "$(COLOR_BLUE)▶$(COLOR_RESET) Enabling SSO auth (switching to Keycloak OIDC)..."; \ + SSO_HOST="http://localhost:$(KIND_FWD_FRONTEND_PORT)"; \ + kubectl set env deployment/frontend -n $(NAMESPACE) \ + SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true \ + SSO_REDIRECT_URI="$$SSO_HOST/api/auth/sso/callback" \ + SSO_PUBLIC_ISSUER_URL="$$SSO_HOST/sso/realms/ambient-code"; \ + kubectl set env deployment/backend-api -n $(NAMESPACE) \ + SSO_PUBLIC_ISSUER_URL="$$SSO_HOST/sso/realms/ambient-code"; \ + kubectl set env deployment/keycloak -n $(NAMESPACE) \ + KC_HOSTNAME="$$SSO_HOST/sso"; \ + kubectl port-forward -n $(NAMESPACE) svc/unleash 4242:4242 >/dev/null 2>&1 & PF=$$!; sleep 2; \ + curl -sf -X POST "http://localhost:4242/api/admin/projects/default/features/sso-authentication/environments/development/on" \ + -H "Authorization: $$UNLEASH_ADMIN_TOKEN" >/dev/null 2>&1 || true; \ + kill $$PF 2>/dev/null; \ + echo "$(COLOR_GREEN)✓$(COLOR_RESET) SSO enabled at $$SSO_HOST"; \ + fi + @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Waiting for rollouts..." + @kubectl rollout status deployment/keycloak -n $(NAMESPACE) --timeout=120s >/dev/null 2>&1 || true + @kubectl rollout status deployment/frontend -n $(NAMESPACE) --timeout=60s >/dev/null 2>&1 + @# Restart backend after Keycloak is ready (OIDC discovery needs Keycloak) + @kubectl rollout restart deployment/backend-api -n $(NAMESPACE) >/dev/null 2>&1 || true + @kubectl rollout status deployment/backend-api -n $(NAMESPACE) --timeout=60s >/dev/null 2>&1 || true + @echo "$(COLOR_GREEN)✓$(COLOR_RESET) Done. Restart port-forwards if needed: make kind-port-forward" + kind-status: check-kind ## Show all kind clusters and their port assignments @echo "$(COLOR_BOLD)Kind Cluster Status$(COLOR_RESET)" @echo "" diff --git a/components/backend/README.md b/components/backend/README.md index 9510aa5d8..03b4eede3 100644 --- a/components/backend/README.md +++ b/components/backend/README.md @@ -34,86 +34,50 @@ make run make dev ``` -### Migration from `DISABLE_AUTH` (removed) +### Authentication -Older dev flows sometimes relied on `DISABLE_AUTH=true` to bypass auth. That pattern is **removed**. -The backend **never** bypasses authentication based on environment variables, and it **never** falls back to the backend’s in-cluster ServiceAccount for user-initiated operations. +The backend supports two auth modes, controlled by the `sso-authentication` Unleash feature flag. This is an **infrastructure flag** — it is not visible in the workspace settings UI and is not user-configurable. It is enabled per-environment by the ops team during SSO migration. -#### What changed +**SSO mode (flag on):** The backend validates JWTs from Keycloak against the JWKS endpoint, extracts identity from OIDC claims (`email`, `preferred_username`, `groups`), and uses K8s impersonation for all API calls. API keys (K8s ServiceAccount tokens) are accepted via TokenReview fallback. -- **Removed**: `DISABLE_AUTH`-based bypass (and similar env-var bypasses) -- **Required**: All authenticated endpoints must receive a real Kubernetes/OpenShift token +**Legacy mode (flag off):** The backend reads `X-Forwarded-Access-Token` or `Authorization: Bearer` headers and uses the raw token as the K8s bearer token (OAuth proxy flow). -#### What to do in your local dev workflow +In the Kind dev cluster, legacy mode is the default. Toggle SSO on/off with `make kind-sso-toggle` (affects both frontend and backend). -1. **Stop setting** `DISABLE_AUTH=true` anywhere (shell profile, `.env`, compose, manifests). -2. **Send a token** on requests: - - `Authorization: Bearer ` (preferred) - - `X-Forwarded-Access-Token: ` (when behind an auth proxy) -3. If you get: - - **401**: token missing/invalid/malformed - - **403**: token valid but RBAC forbids the operation in that namespace +#### Local development (Kind) -#### Option A: OpenShift / CRC (recommended for this repo) +`make kind-up` deploys Keycloak automatically. The backend is configured with: +- `SSO_ISSUER_URL` — points to the in-cluster Keycloak +- `SSO_AUDIENCE` — `ambient-frontend` -```bash -# Login and obtain a user token -oc login ... -export OC_TOKEN="$(oc whoami -t)" - -# Example request -curl -H "Authorization: Bearer ${OC_TOKEN}" \ - http://localhost:8080/health -``` - -#### Option B: kind (ServiceAccount token for local dev) - -Kubernetes v1.24+ supports `kubectl create token`: - -```bash -export DEV_NS=ambient-code -kubectl create namespace "${DEV_NS}" 2>/dev/null || true - -kubectl -n "${DEV_NS}" create serviceaccount backend-dev 2>/dev/null || true - -# Minimal example permissions (adjust as needed) -kubectl -n "${DEV_NS}" create role backend-dev \ - --verb=get,list,watch,create,update,patch,delete \ - --resource=secrets,configmaps,services,pods,rolebindings 2>/dev/null || true - -kubectl -n "${DEV_NS}" create rolebinding backend-dev \ - --role=backend-dev \ - --serviceaccount="${DEV_NS}:backend-dev" 2>/dev/null || true - -export DEV_TOKEN="$(kubectl -n "${DEV_NS}" create token backend-dev)" - -curl -H "Authorization: Bearer ${DEV_TOKEN}" \ - http://localhost:8080/health -``` - -If you’re on an older cluster that does **not** support `kubectl create token`, you can use a legacy Secret-backed token: +To test backend endpoints directly with a Keycloak JWT: ```bash -export DEV_NS=ambient-code -kubectl -n "${DEV_NS}" create serviceaccount backend-dev 2>/dev/null || true - -SECRET_NAME="$(kubectl -n "${DEV_NS}" get sa backend-dev -o jsonpath='{.secrets[0].name}')" -export DEV_TOKEN="$(kubectl -n "${DEV_NS}" get secret "${SECRET_NAME}" -o jsonpath='{.data.token}' | base64 -d)" +# Get a JWT from Keycloak (from within the cluster) +JWT=$(kubectl run -n ambient-code jwt-dev --rm -i --restart=Never --quiet \ + --image=curlimages/curl -- sh -c \ + ‘curl -sf -X POST http://keycloak-service:8080/realms/ambient-code/protocol/openid-connect/token \ + -d client_id=ambient-frontend \ + -d client_secret=dev-secret-do-not-use-in-prod \ + -d grant_type=password \ + -d username=developer \ + -d password=developer \ + -d scope=openid’ 2>/dev/null | jq -r ‘.access_token’) + +curl -H "Authorization: Bearer $JWT" http://localhost:12646/api/projects ``` -#### Calling project-scoped APIs (example) +K8s ServiceAccount tokens also work (dual-path auth): ```bash -export TOKEN="..." -export PROJECT="my-project" - -curl -H "Authorization: Bearer ${TOKEN}" \ - "http://localhost:8080/api/projects/${PROJECT}/agentic-sessions" +TOKEN=$(kubectl get secret test-user-token -n ambient-code \ + -o jsonpath=’{.data.token}’ | base64 -d) +curl -H "Authorization: Bearer $TOKEN" http://localhost:12646/api/projects ``` #### Unit tests note -Unit tests **must not** use `DISABLE_AUTH`. Handler unit tests use: +Unit tests use: - `go test -tags=test ./handlers` - `SetValidTestToken(...)` (see `components/backend/tests/test_utils/http_utils.go`) diff --git a/components/backend/go.mod b/components/backend/go.mod index 92f612891..e52b2413e 100644 --- a/components/backend/go.mod +++ b/components/backend/go.mod @@ -13,6 +13,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/lestrrat-go/jwx/v2 v2.1.6 github.com/minio/minio-go/v7 v7.0.82 github.com/onsi/ginkgo/v2 v2.27.3 github.com/onsi/gomega v1.38.3 @@ -33,6 +34,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -65,6 +67,11 @@ require ( github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/launchdarkly/eventsource v1.10.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/md5-simd v1.1.2 // indirect @@ -75,6 +82,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/xid v1.6.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/gjson v1.18.0 // indirect diff --git a/components/backend/go.sum b/components/backend/go.sum index c8a632e4c..88de5a8cd 100644 --- a/components/backend/go.sum +++ b/components/backend/go.sum @@ -35,6 +35,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= @@ -187,6 +189,18 @@ github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1z github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= @@ -226,6 +240,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -234,6 +250,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= diff --git a/components/backend/handlers/middleware.go b/components/backend/handlers/middleware.go index feca4f3ce..7c082a49f 100644 --- a/components/backend/handlers/middleware.go +++ b/components/backend/handlers/middleware.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "ambient-code-backend/jwtauth" "ambient-code-backend/server" "github.com/gin-gonic/gin" @@ -88,38 +89,50 @@ func getK8sClientsDefault(c *gin.Context) (kubernetes.Interface, dynamic.Interfa // All requests must provide a valid user token. No environment variable checks. // No fallback to service account credentials. - if token != "" && BaseKubeConfig != nil { - cfg := *BaseKubeConfig - cfg.BearerToken = token - // Ensure we do NOT fall back to the in-cluster SA token or other auth providers - cfg.BearerTokenFile = "" - cfg.AuthProvider = nil - cfg.ExecProvider = nil - cfg.Username = "" - cfg.Password = "" - - kc, err1 := kubernetes.NewForConfig(&cfg) - dc, err2 := dynamic.NewForConfig(&cfg) + if token == "" { + log.Printf("No user token found for %s (tokenSource=%s hasAuthHeader=%t hasFwdToken=%t)", c.FullPath(), tokenSource, hasAuthHeader, hasFwdToken) + return nil, nil + } - if err1 == nil && err2 == nil { + if BaseKubeConfig == nil { + log.Printf("Cannot build user-scoped k8s clients: BaseKubeConfig is nil (source=%s tokenLen=%d) for %s", tokenSource, len(token), c.FullPath()) + return nil, nil + } - // Best-effort update last-used for service account tokens + // SSO path: use K8s impersonation with validated JWT or API key + if SSOEnabled() { + // Reuse JWT claims validated by forwardedIdentityMiddleware + if claims, exists := c.Get("ssoValidatedClaims"); exists { updateAccessKeyLastUsedAnnotation(c) - return kc, dc + return buildImpersonatingClients(claims.(*jwtauth.Claims)) } - // Token provided but client build failed – treat as invalid token - log.Printf("Failed to build user-scoped k8s clients (source=%s tokenLen=%d) typedErr=%v dynamicErr=%v for %s", tokenSource, len(token), err1, err2, c.FullPath()) + // JWT validation failed in middleware — try TokenReview fallback for API keys + if userName, groups, ok := tokenReviewIdentity(c, token); ok { + setIdentityFromTokenReview(c, userName, groups) + updateAccessKeyLastUsedAnnotation(c) + return buildImpersonatingClientsFromIdentity(userName, groups) + } + log.Printf("SSO: token failed both JWT and TokenReview for %s (source=%s tokenLen=%d)", c.FullPath(), tokenSource, len(token)) return nil, nil } - if token != "" && BaseKubeConfig == nil { - // Token was provided but the backend is misconfigured; don't pretend it's a missing token. - log.Printf("Cannot build user-scoped k8s clients: BaseKubeConfig is nil (source=%s tokenLen=%d) for %s", tokenSource, len(token), c.FullPath()) - return nil, nil + // Legacy path: use raw token as BearerToken + cfg := *BaseKubeConfig + cfg.BearerToken = token + cfg.BearerTokenFile = "" + cfg.AuthProvider = nil + cfg.ExecProvider = nil + cfg.Username = "" + cfg.Password = "" + + kc, err1 := kubernetes.NewForConfig(&cfg) + dc, err2 := dynamic.NewForConfig(&cfg) + + if err1 == nil && err2 == nil { + updateAccessKeyLastUsedAnnotation(c) + return kc, dc } - - // No token provided (or headers present but parsed to empty token) - log.Printf("No user token found for %s (tokenSource=%s hasAuthHeader=%t hasFwdToken=%t)", c.FullPath(), tokenSource, hasAuthHeader, hasFwdToken) + log.Printf("Failed to build user-scoped k8s clients (source=%s tokenLen=%d) typedErr=%v dynamicErr=%v for %s", tokenSource, len(token), err1, err2, c.FullPath()) return nil, nil } @@ -312,8 +325,14 @@ func ValidateProjectContext() gin.HandlerFunc { // Ensure the caller has at least list permission on agenticsessions in the namespace. // Check the SSAR cache first to avoid hitting the K8s API on every request. + // Under SSO, use the authenticated identity (not the shared SA token) to prevent + // cross-user cache leaks. token, _, _, _ := extractRequestToken(c) - cacheKey := ssarCacheKey(token, projectHeader, "list", "vteam.ambient-code", "agenticsessions") + cacheIdentity := c.GetString("authIdentity") + if cacheIdentity == "" { + cacheIdentity = token + } + cacheKey := ssarCacheKey(cacheIdentity, projectHeader, "list", "vteam.ambient-code", "agenticsessions") if cachedAllowed, found := globalSSARCache.check(cacheKey); found { if !cachedAllowed { diff --git a/components/backend/handlers/models_test.go b/components/backend/handlers/models_test.go index 8f79b7123..3aa10f02d 100755 --- a/components/backend/handlers/models_test.go +++ b/components/backend/handlers/models_test.go @@ -20,6 +20,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/kubernetes" k8sfake "k8s.io/client-go/kubernetes/fake" @@ -28,10 +29,10 @@ import ( var _ = Describe("Models Handler", Label(test_constants.LabelUnit, test_constants.LabelHandlers), func() { var ( httpTestUtils *test_utils.HTTPTestUtils - originalK8s = K8sClient - originalNs = Namespace - originalK8sClientMw = K8sClientMw - originalDynClient = DynamicClient + originalK8s kubernetes.Interface + originalNs string + originalK8sClientMw kubernetes.Interface + originalDynClient dynamic.Interface validManifest string ) @@ -54,6 +55,11 @@ var _ = Describe("Models Handler", Label(test_constants.LabelUnit, test_constant } BeforeEach(func() { + originalK8s = K8sClient + originalNs = Namespace + originalK8sClientMw = K8sClientMw + originalDynClient = DynamicClient + httpTestUtils = test_utils.NewHTTPTestUtils() manifestBytes, err := json.Marshal(validManifestObj) Expect(err).NotTo(HaveOccurred()) diff --git a/components/backend/handlers/projects.go b/components/backend/handlers/projects.go index a84929f1d..a059c067a 100644 --- a/components/backend/handlers/projects.go +++ b/components/backend/handlers/projects.go @@ -930,8 +930,10 @@ func checkUserCanAccessNamespace(userClient kubernetes.Interface, namespace stri return checkUserCanViewProject(userClient, namespace) } -// getUserSubjectFromContext extracts the user subject from the JWT token in the request -// Returns subject in format like "user@example.com" or "system:serviceaccount:namespace:name" +// getUserSubjectFromContext extracts the user subject from the JWT token in the request. +// Returns subject in format like "user@example.com" or "system:serviceaccount:namespace:name". +// The subject must match the identity used for K8s impersonation so that RoleBindings +// created here are effective for subsequent RBAC checks. func getUserSubjectFromContext(c *gin.Context) (string, error) { // Try to extract from ServiceAccount first ns, saName, ok := ExtractServiceAccountFromAuth(c) @@ -939,7 +941,14 @@ func getUserSubjectFromContext(c *gin.Context) (string, error) { return fmt.Sprintf("system:serviceaccount:%s:%s", ns, saName), nil } - // Otherwise try to get from context (set by middleware) + // Prefer email — this matches the impersonation identity (Impersonate-User) + // so RoleBindings created with this subject are effective under impersonation. + if email := c.GetString("userEmail"); email != "" { + return email, nil + } + if userIDOrig := c.GetString("userIDOriginal"); userIDOrig != "" { + return userIDOrig, nil + } if userName, exists := c.Get("userName"); exists && userName != nil { return fmt.Sprintf("%v", userName), nil } diff --git a/components/backend/handlers/ssar_cache.go b/components/backend/handlers/ssar_cache.go index 2245b1f5b..18c47fac6 100644 --- a/components/backend/handlers/ssar_cache.go +++ b/components/backend/handlers/ssar_cache.go @@ -40,9 +40,11 @@ var globalSSARCache = &ssarCache{ } // ssarCacheKey builds a cache key from the request parameters. -// The token is hashed so raw credentials are never stored. -func ssarCacheKey(token, namespace, verb, group, resource string) string { - h := sha256.Sum256([]byte(token)) +// The identity parameter is hashed so raw credentials are never stored. +// Under SSO, identity is the user's OIDC sub claim (unique per user). +// Under legacy auth, identity is the raw bearer token. +func ssarCacheKey(identity, namespace, verb, group, resource string) string { + h := sha256.Sum256([]byte(identity)) return fmt.Sprintf("%x:%s:%s:%s:%s", h[:8], namespace, verb, group, resource) } diff --git a/components/backend/handlers/sso.go b/components/backend/handlers/sso.go new file mode 100644 index 000000000..0a0ee0a0f --- /dev/null +++ b/components/backend/handlers/sso.go @@ -0,0 +1,124 @@ +package handlers + +import ( + "log" + "strings" + + "ambient-code-backend/featureflags" + "ambient-code-backend/jwtauth" + "ambient-code-backend/server" + + "github.com/gin-gonic/gin" + authnv1 "k8s.io/api/authentication/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +const ssoFeatureFlag = "sso-authentication" + +func SSOEnabled() bool { + return featureflags.IsEnabled(ssoFeatureFlag) +} + +func buildImpersonatingClients(claims *jwtauth.Claims) (kubernetes.Interface, dynamic.Interface) { + if BaseKubeConfig == nil { + log.Printf("SSO: cannot build impersonating clients: BaseKubeConfig is nil") + return nil, nil + } + + impersonateUser := claims.Email + if impersonateUser == "" { + impersonateUser = claims.PreferredUsername + } + if impersonateUser == "" { + impersonateUser = claims.Sub + } + if impersonateUser == "" { + log.Printf("SSO: JWT has no usable identity claim (email, preferred_username, sub)") + return nil, nil + } + + cfg := rest.CopyConfig(BaseKubeConfig) + cfg.Impersonate = rest.ImpersonationConfig{ + UserName: impersonateUser, + Groups: claims.Groups, + } + + kc, err1 := kubernetes.NewForConfig(cfg) + dc, err2 := dynamic.NewForConfig(cfg) + if err1 != nil || err2 != nil { + log.Printf("SSO: failed to build impersonating clients for %s: typed=%v dynamic=%v", impersonateUser, err1, err2) + return nil, nil + } + + return kc, dc +} + +func tokenReviewIdentity(c *gin.Context, token string) (userName string, groups []string, ok bool) { + if K8sClientMw == nil { + return "", nil, false + } + + tr := &authnv1.TokenReview{Spec: authnv1.TokenReviewSpec{Token: token}} + rv, err := K8sClientMw.AuthenticationV1().TokenReviews().Create(c.Request.Context(), tr, v1.CreateOptions{}) + if err != nil || !rv.Status.Authenticated || rv.Status.Error != "" { + return "", nil, false + } + + username := strings.TrimSpace(rv.Status.User.Username) + if username == "" { + return "", nil, false + } + + // For service accounts, resolve the creating user's identity from annotations + const saPrefix = "system:serviceaccount:" + if strings.HasPrefix(username, saPrefix) { + rest := strings.TrimPrefix(username, saPrefix) + parts := strings.SplitN(rest, ":", 2) + if len(parts) == 2 { + sa, err := K8sClientMw.CoreV1().ServiceAccounts(parts[0]).Get(c.Request.Context(), parts[1], v1.GetOptions{}) + if err == nil && sa.Annotations != nil { + if uid := sa.Annotations["ambient-code.io/created-by-user-id"]; uid != "" { + username = uid + } + } + } + } + + return username, rv.Status.User.Groups, true +} + +func buildImpersonatingClientsFromIdentity(userName string, groups []string) (kubernetes.Interface, dynamic.Interface) { + if BaseKubeConfig == nil || userName == "" { + return nil, nil + } + + cfg := rest.CopyConfig(BaseKubeConfig) + cfg.Impersonate = rest.ImpersonationConfig{ + UserName: userName, + Groups: groups, + } + + kc, err1 := kubernetes.NewForConfig(cfg) + dc, err2 := dynamic.NewForConfig(cfg) + if err1 != nil || err2 != nil { + log.Printf("SSO: failed to build impersonating clients for identity %s: typed=%v dynamic=%v", userName, err1, err2) + return nil, nil + } + + return kc, dc +} + +func setIdentityFromTokenReview(c *gin.Context, userName string, groups []string) { + c.Set("userID", server.SanitizeUserID(userName)) + c.Set("userIDOriginal", userName) + c.Set("userName", userName) + + if len(groups) > 0 { + c.Set("userGroups", groups) + } + + c.Set("authIdentity", userName) +} diff --git a/components/backend/jwtauth/validator.go b/components/backend/jwtauth/validator.go new file mode 100644 index 000000000..1a8a76889 --- /dev/null +++ b/components/backend/jwtauth/validator.go @@ -0,0 +1,198 @@ +// Package jwtauth provides JWT validation against OIDC providers using JWKS. +package jwtauth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +type Claims struct { + Sub string + Email string + PreferredUsername string + Groups []string + Issuer string + Audience []string + ExpiresAt time.Time +} + +type Validator struct { + jwksCache *jwk.Cache + jwksURL string + issuer string + altIssuers []string + audience string +} + +func NewValidator(issuerURL, audience string) (*Validator, error) { + if issuerURL == "" { + return nil, fmt.Errorf("issuer URL is required") + } + + discoveredIssuer, jwksURL, err := discoverOIDCConfig(issuerURL) + if err != nil { + return nil, fmt.Errorf("OIDC discovery failed: %w", err) + } + + ctx := context.Background() + cache := jwk.NewCache(ctx) + if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(5*time.Minute)); err != nil { + return nil, fmt.Errorf("failed to register JWKS URL: %w", err) + } + + if _, err := cache.Refresh(ctx, jwksURL); err != nil { + return nil, fmt.Errorf("failed to fetch JWKS: %w", err) + } + + return &Validator{ + jwksCache: cache, + jwksURL: jwksURL, + issuer: discoveredIssuer, + audience: audience, + }, nil +} + +func NewValidatorWithJWKSURL(jwksURL, issuer, audience string) (*Validator, error) { + if jwksURL == "" { + return nil, fmt.Errorf("JWKS URL is required") + } + + ctx := context.Background() + cache := jwk.NewCache(ctx) + if err := cache.Register(jwksURL, jwk.WithMinRefreshInterval(5*time.Minute)); err != nil { + return nil, fmt.Errorf("failed to register JWKS URL: %w", err) + } + + if _, err := cache.Refresh(ctx, jwksURL); err != nil { + return nil, fmt.Errorf("failed to fetch JWKS: %w", err) + } + + return &Validator{ + jwksCache: cache, + jwksURL: jwksURL, + issuer: issuer, + audience: audience, + }, nil +} + +// AddAltIssuer adds an alternative accepted issuer URL. Tokens signed by the +// same JWKS keys but with a different iss claim (e.g., the public URL of a +// Keycloak behind a port-forward) will be accepted. +func (v *Validator) AddAltIssuer(issuer string) { + if issuer != "" && issuer != v.issuer { + v.altIssuers = append(v.altIssuers, issuer) + } +} + +func (v *Validator) Validate(tokenString string) (*Claims, error) { + keySet, err := v.jwksCache.Get(context.Background(), v.jwksURL) + if err != nil { + return nil, fmt.Errorf("failed to get JWKS: %w", err) + } + + // Verify signature and expiration, but validate issuer manually to support + // multiple accepted issuers (internal + public URL in dev environments). + opts := []jwt.ParseOption{ + jwt.WithKeySet(keySet), + jwt.WithValidate(true), + } + if v.audience != "" { + opts = append(opts, jwt.WithAudience(v.audience)) + } + + token, err := jwt.Parse([]byte(tokenString), opts...) + if err != nil { + return nil, fmt.Errorf("token validation failed: %w", err) + } + + if !v.isAcceptedIssuer(token.Issuer()) { + return nil, fmt.Errorf("token validation failed: issuer %q not accepted", token.Issuer()) + } + + claims := &Claims{ + Sub: token.Subject(), + Issuer: token.Issuer(), + ExpiresAt: token.Expiration(), + } + + if aud := token.Audience(); len(aud) > 0 { + claims.Audience = aud + } + + privateClaims := token.PrivateClaims() + + if email, ok := privateClaims["email"].(string); ok { + claims.Email = email + } + + if username, ok := privateClaims["preferred_username"].(string); ok { + claims.PreferredUsername = username + } + + if groups, ok := privateClaims["groups"]; ok { + switch g := groups.(type) { + case []interface{}: + for _, item := range g { + if s, ok := item.(string); ok { + claims.Groups = append(claims.Groups, s) + } + } + case []string: + claims.Groups = g + } + } + + return claims, nil +} + +func (v *Validator) isAcceptedIssuer(iss string) bool { + if iss == v.issuer { + return true + } + for _, alt := range v.altIssuers { + if iss == alt { + return true + } + } + return false +} + +func discoverOIDCConfig(issuerURL string) (discoveredIssuer string, jwksURL string, err error) { + wellKnownURL := issuerURL + "/.well-known/openid-configuration" + + httpClient := &http.Client{Timeout: 10 * time.Second} + resp, err := httpClient.Get(wellKnownURL) + if err != nil { + return "", "", fmt.Errorf("failed to fetch OIDC configuration: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("OIDC configuration returned status %d", resp.StatusCode) + } + + var config struct { + Issuer string `json:"issuer"` + JWKSURI string `json:"jwks_uri"` + } + if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { + return "", "", fmt.Errorf("failed to decode OIDC configuration: %w", err) + } + + if config.JWKSURI == "" { + return "", "", fmt.Errorf("OIDC configuration missing jwks_uri") + } + + issuer := config.Issuer + if issuer == "" { + issuer = issuerURL + } + + return issuer, config.JWKSURI, nil +} diff --git a/components/backend/jwtauth/validator_test.go b/components/backend/jwtauth/validator_test.go new file mode 100644 index 000000000..bae3d205a --- /dev/null +++ b/components/backend/jwtauth/validator_test.go @@ -0,0 +1,318 @@ +package jwtauth + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +func setupTestServer(t *testing.T) (*rsa.PrivateKey, *httptest.Server) { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + key, err := jwk.FromRaw(privateKey.PublicKey) + if err != nil { + t.Fatal(err) + } + if err := key.Set(jwk.KeyIDKey, "test-key-1"); err != nil { + t.Fatal(err) + } + if err := key.Set(jwk.AlgorithmKey, jwa.RS256); err != nil { + t.Fatal(err) + } + if err := key.Set(jwk.KeyUsageKey, "sig"); err != nil { + t.Fatal(err) + } + + keySet := jwk.NewSet() + if err := keySet.AddKey(key); err != nil { + t.Fatal(err) + } + + mux := http.NewServeMux() + var serverURL string + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + config := map[string]string{ + "issuer": serverURL, + "jwks_uri": serverURL + "/jwks", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(config) + }) + + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(keySet) + }) + + server := httptest.NewServer(mux) + serverURL = server.URL + + return privateKey, server +} + +func signToken(t *testing.T, privateKey *rsa.PrivateKey, token jwt.Token) string { + t.Helper() + + signingKey, err := jwk.FromRaw(privateKey) + if err != nil { + t.Fatal(err) + } + if err := signingKey.Set(jwk.KeyIDKey, "test-key-1"); err != nil { + t.Fatal(err) + } + if err := signingKey.Set(jwk.AlgorithmKey, jwa.RS256); err != nil { + t.Fatal(err) + } + + signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, signingKey)) + if err != nil { + t.Fatal(err) + } + return string(signed) +} + +func TestValidate(t *testing.T) { + privateKey, server := setupTestServer(t) + defer server.Close() + + validator, err := NewValidator(server.URL, "ambient-frontend") + if err != nil { + t.Fatalf("NewValidator: %v", err) + } + + tests := []struct { + name string + buildToken func() string + wantErr bool + checkClaims func(*testing.T, *Claims) + }{ + { + name: "valid token with all claims", + buildToken: func() string { + tok, _ := jwt.NewBuilder(). + Subject("f:abc:jsell"). + Issuer(server.URL). + Audience([]string{"ambient-frontend"}). + Expiration(time.Now().Add(5*time.Minute)). + IssuedAt(time.Now()). + Claim("email", "jsell@redhat.com"). + Claim("preferred_username", "jsell"). + Claim("groups", []string{"ambient-users", "team-ambient"}). + Build() + return signToken(t, privateKey, tok) + }, + wantErr: false, + checkClaims: func(t *testing.T, c *Claims) { + if c.Sub != "f:abc:jsell" { + t.Errorf("Sub = %q, want %q", c.Sub, "f:abc:jsell") + } + if c.Email != "jsell@redhat.com" { + t.Errorf("Email = %q, want %q", c.Email, "jsell@redhat.com") + } + if c.PreferredUsername != "jsell" { + t.Errorf("PreferredUsername = %q, want %q", c.PreferredUsername, "jsell") + } + if len(c.Groups) != 2 || c.Groups[0] != "ambient-users" { + t.Errorf("Groups = %v, want [ambient-users, team-ambient]", c.Groups) + } + if c.Issuer != server.URL { + t.Errorf("Issuer = %q, want %q", c.Issuer, server.URL) + } + }, + }, + { + name: "expired token", + buildToken: func() string { + tok, _ := jwt.NewBuilder(). + Subject("expired-user"). + Issuer(server.URL). + Audience([]string{"ambient-frontend"}). + Expiration(time.Now().Add(-1 * time.Hour)). + IssuedAt(time.Now().Add(-2 * time.Hour)). + Build() + return signToken(t, privateKey, tok) + }, + wantErr: true, + }, + { + name: "wrong issuer", + buildToken: func() string { + tok, _ := jwt.NewBuilder(). + Subject("wrong-issuer-user"). + Issuer("https://evil.example.com"). + Audience([]string{"ambient-frontend"}). + Expiration(time.Now().Add(5 * time.Minute)). + IssuedAt(time.Now()). + Build() + return signToken(t, privateKey, tok) + }, + wantErr: true, + }, + { + name: "wrong audience", + buildToken: func() string { + tok, _ := jwt.NewBuilder(). + Subject("wrong-audience-user"). + Issuer(server.URL). + Audience([]string{"wrong-audience"}). + Expiration(time.Now().Add(5 * time.Minute)). + IssuedAt(time.Now()). + Build() + return signToken(t, privateKey, tok) + }, + wantErr: true, + }, + { + name: "tampered signature", + buildToken: func() string { + otherKey, _ := rsa.GenerateKey(rand.Reader, 2048) + tok, _ := jwt.NewBuilder(). + Subject("tampered-user"). + Issuer(server.URL). + Audience([]string{"ambient-frontend"}). + Expiration(time.Now().Add(5 * time.Minute)). + IssuedAt(time.Now()). + Build() + return signToken(t, otherKey, tok) + }, + wantErr: true, + }, + { + name: "malformed token", + buildToken: func() string { + return "not.a.jwt" + }, + wantErr: true, + }, + { + name: "empty token", + buildToken: func() string { + return "" + }, + wantErr: true, + }, + { + name: "token with minimal claims", + buildToken: func() string { + tok, _ := jwt.NewBuilder(). + Subject("minimal-user"). + Issuer(server.URL). + Audience([]string{"ambient-frontend"}). + Expiration(time.Now().Add(5 * time.Minute)). + IssuedAt(time.Now()). + Build() + return signToken(t, privateKey, tok) + }, + wantErr: false, + checkClaims: func(t *testing.T, c *Claims) { + if c.Sub != "minimal-user" { + t.Errorf("Sub = %q, want %q", c.Sub, "minimal-user") + } + if c.Email != "" { + t.Errorf("Email = %q, want empty", c.Email) + } + if c.PreferredUsername != "" { + t.Errorf("PreferredUsername = %q, want empty", c.PreferredUsername) + } + if len(c.Groups) != 0 { + t.Errorf("Groups = %v, want empty", c.Groups) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tokenStr := tt.buildToken() + claims, err := validator.Validate(tokenStr) + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && tt.checkClaims != nil { + tt.checkClaims(t, claims) + } + }) + } +} + +func TestNewValidator_MissingIssuer(t *testing.T) { + _, err := NewValidator("", "audience") + if err == nil { + t.Fatal("expected error for empty issuer URL") + } +} + +func TestNewValidator_BadIssuer(t *testing.T) { + _, err := NewValidator("http://localhost:1/nonexistent", "audience") + if err == nil { + t.Fatal("expected error for unreachable issuer") + } +} + +func TestNewValidatorWithJWKSURL(t *testing.T) { + privateKey, server := setupTestServer(t) + defer server.Close() + + validator, err := NewValidatorWithJWKSURL(server.URL+"/jwks", server.URL, "ambient-frontend") + if err != nil { + t.Fatalf("NewValidatorWithJWKSURL: %v", err) + } + + tok, _ := jwt.NewBuilder(). + Subject("direct-jwks-user"). + Issuer(server.URL). + Audience([]string{"ambient-frontend"}). + Expiration(time.Now().Add(5 * time.Minute)). + IssuedAt(time.Now()). + Build() + + tokenStr := signToken(t, privateKey, tok) + claims, err := validator.Validate(tokenStr) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if claims.Sub != "direct-jwks-user" { + t.Errorf("Sub = %q, want %q", claims.Sub, "direct-jwks-user") + } +} + +func TestDiscoverJWKSURL(t *testing.T) { + mux := http.NewServeMux() + var serverURL string + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"jwks_uri": "%s/jwks", "issuer": "%s"}`, serverURL, serverURL) + }) + + server := httptest.NewServer(mux) + defer server.Close() + serverURL = server.URL + + issuer, jwksURL, err := discoverOIDCConfig(server.URL) + if err != nil { + t.Fatalf("discoverOIDCConfig: %v", err) + } + if issuer != server.URL { + t.Errorf("issuer = %q, want %q", issuer, server.URL) + } + expected := server.URL + "/jwks" + if jwksURL != expected { + t.Errorf("jwksURL = %q, want %q", jwksURL, expected) + } +} diff --git a/components/backend/main.go b/components/backend/main.go index 183e3962f..717f23925 100644 --- a/components/backend/main.go +++ b/components/backend/main.go @@ -81,6 +81,7 @@ func main() { } server.InitConfig() + server.InitJWTValidator() // Optional: Unleash feature flags (when UNLEASH_URL and UNLEASH_CLIENT_KEY are set) featureflags.Init() diff --git a/components/backend/server/k8s.go b/components/backend/server/k8s.go index 8a9c768eb..6f6949969 100644 --- a/components/backend/server/k8s.go +++ b/components/backend/server/k8s.go @@ -2,8 +2,11 @@ package server import ( "fmt" + "log" "os" + "ambient-code-backend/jwtauth" + "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" @@ -19,6 +22,7 @@ var ( BaseKubeConfig *rest.Config OperatorImage string ImagePullPolicy string + JWTValidator *jwtauth.Validator ) // InitK8sClients initializes Kubernetes clients and configuration @@ -62,6 +66,32 @@ func InitK8sClients() error { return nil } +// InitJWTValidator initializes the JWT validator for SSO authentication. +// Non-fatal: if SSO_ISSUER_URL is not configured, the validator is left nil +// and SSO auth is unavailable (the feature flag will also be off). +func InitJWTValidator() { + issuerURL := os.Getenv("SSO_ISSUER_URL") + audience := os.Getenv("SSO_AUDIENCE") + if issuerURL == "" { + log.Printf("SSO: JWT validation not configured (SSO_ISSUER_URL not set)") + return + } + + v, err := jwtauth.NewValidator(issuerURL, audience) + if err != nil { + log.Printf("SSO: failed to initialize JWT validator: %v", err) + return + } + + if altIssuer := os.Getenv("SSO_PUBLIC_ISSUER_URL"); altIssuer != "" { + v.AddAltIssuer(altIssuer) + log.Printf("SSO: added alt issuer %s", altIssuer) + } + + JWTValidator = v + log.Printf("SSO: JWT validator initialized (issuer=%s, audience=%s)", issuerURL, audience) +} + // InitConfig initializes configuration from environment variables func InitConfig() { // Get namespace from environment or use default diff --git a/components/backend/server/server.go b/components/backend/server/server.go index 1bb440e5a..020c88337 100755 --- a/components/backend/server/server.go +++ b/components/backend/server/server.go @@ -7,6 +7,9 @@ import ( "os" "strings" + "ambient-code-backend/featureflags" + "ambient-code-backend/jwtauth" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" authnv1 "k8s.io/api/authentication/v1" @@ -76,7 +79,7 @@ func Run(registerRoutes RouterFunc) error { return nil } -// sanitizeUserID converts userID to a valid Kubernetes Secret data key +// SanitizeUserID converts userID to a valid Kubernetes Secret data key // K8s Secret keys must match regex: [-._a-zA-Z0-9]+ // Follows cert-manager's sanitization pattern for consistent, secure key generation // @@ -88,7 +91,7 @@ func Run(registerRoutes RouterFunc) error { // - Spaces: "First Last" → "First-Last" // // Security: Only replaces characters, never interprets them (no injection risk) -func sanitizeUserID(userID string) string { +func SanitizeUserID(userID string) string { if userID == "" { return "" } @@ -128,17 +131,29 @@ func sanitizeUserID(userID string) string { return sanitized } -// forwardedIdentityMiddleware populates Gin context from common OAuth proxy headers +// forwardedIdentityMiddleware populates Gin context from common OAuth proxy headers. +// Under SSO, it validates the JWT and extracts identity from claims instead. func forwardedIdentityMiddleware() gin.HandlerFunc { return func(c *gin.Context) { + // SSO path: extract identity from JWT Bearer token + if featureflags.IsEnabled("sso-authentication") && JWTValidator != nil { + if token := extractBearerToken(c); token != "" { + if claims, err := JWTValidator.Validate(token); err == nil { + setIdentityFromClaims(c, claims) + c.Set("ssoValidatedClaims", claims) + c.Next() + return + } + // JWT validation failed — fall through to header-based extraction + // (API keys will be handled by getK8sClientsDefault via TokenReview) + } + } + + // Legacy path: extract identity from OAuth proxy headers if v := c.GetHeader("X-Forwarded-User"); v != "" { - // Sanitize userID to make it valid for K8s Secret keys - // Example: "kube:admin" becomes "kube-admin" - c.Set("userID", sanitizeUserID(v)) - // Keep original for display purposes + c.Set("userID", SanitizeUserID(v)) c.Set("userIDOriginal", v) } - // Prefer preferred username; fallback to user id name := c.GetHeader("X-Forwarded-Preferred-Username") if name == "" { name = c.GetHeader("X-Forwarded-User") @@ -152,7 +167,6 @@ func forwardedIdentityMiddleware() gin.HandlerFunc { if v := c.GetHeader("X-Forwarded-Groups"); v != "" { c.Set("userGroups", strings.Split(v, ",")) } - // Also expose access token if present auth := c.GetHeader("Authorization") if auth != "" { c.Set("authorizationHeader", auth) @@ -181,6 +195,42 @@ func forwardedIdentityMiddleware() gin.HandlerFunc { } } +func extractBearerToken(c *gin.Context) string { + auth := c.GetHeader("Authorization") + if auth == "" { + return "" + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + return strings.TrimSpace(parts[1]) + } + return "" +} + +func setIdentityFromClaims(c *gin.Context, claims *jwtauth.Claims) { + if claims.Email != "" { + c.Set("userID", SanitizeUserID(claims.Email)) + c.Set("userIDOriginal", claims.Email) + c.Set("userEmail", claims.Email) + } else if claims.Sub != "" { + c.Set("userID", SanitizeUserID(claims.Sub)) + c.Set("userIDOriginal", claims.Sub) + } + + if claims.PreferredUsername != "" { + c.Set("userName", claims.PreferredUsername) + } else if claims.Email != "" { + c.Set("userName", claims.Email) + } + + if len(claims.Groups) > 0 { + c.Set("userGroups", claims.Groups) + } + + c.Set("authIdentity", claims.Sub) + c.Set("authorizationHeader", c.GetHeader("Authorization")) +} + // resolveServiceAccountFromToken verifies the Bearer token via K8s TokenReview // and extracts the ServiceAccount namespace and name from the authenticated identity. // Returns (namespace, saName, true) when verified, otherwise ("","",false). diff --git a/components/backend/server/server_test.go b/components/backend/server/server_test.go index 5229d36ba..798730da4 100644 --- a/components/backend/server/server_test.go +++ b/components/backend/server/server_test.go @@ -69,20 +69,20 @@ func TestSanitizeUserID(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := sanitizeUserID(tt.input) + result := SanitizeUserID(tt.input) if tt.name == "Very long username (truncated to 253)" { // Just check length is <= 253 if len(result) > 253 { - t.Errorf("sanitizeUserID() length = %d, want <= 253", len(result)) + t.Errorf("SanitizeUserID() length = %d, want <= 253", len(result)) } } else if result != tt.expected { - t.Errorf("sanitizeUserID(%q) = %q, want %q", tt.input, result, tt.expected) + t.Errorf("SanitizeUserID(%q) = %q, want %q", tt.input, result, tt.expected) } // Security check: result should only contain valid chars for _, r := range result { if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.') { - t.Errorf("sanitizeUserID(%q) contains invalid character: %q", tt.input, r) + t.Errorf("SanitizeUserID(%q) contains invalid character: %q", tt.input, r) } } }) @@ -98,11 +98,11 @@ func TestSanitizeUserIDDeterministic(t *testing.T) { } for _, input := range inputs { - first := sanitizeUserID(input) + first := SanitizeUserID(input) for i := 0; i < 10; i++ { - result := sanitizeUserID(input) + result := SanitizeUserID(input) if result != first { - t.Errorf("sanitizeUserID() not deterministic: %q != %q", result, first) + t.Errorf("SanitizeUserID() not deterministic: %q != %q", result, first) } } } diff --git a/components/frontend/.env.example b/components/frontend/.env.example index 007edc0ac..4351bcb75 100644 --- a/components/frontend/.env.example +++ b/components/frontend/.env.example @@ -16,15 +16,19 @@ BACKEND_URL=http://localhost:8080/api # Proxied via /api/ambient/v1/... catch-all route API_SERVER_URL=http://localhost:8000 -# Optional: OpenShift identity details for local development -# If you login with 'oc login', you can set these to forward identity headers -OC_TOKEN= -OC_USER= -OC_EMAIL= - -# Optional: Automatically discover OpenShift identity via 'oc whoami' in dev -# Set to '1' or 'true' to enable -ENABLE_OC_WHOAMI=1 +# SSO/OIDC authentication (set automatically in Kind via sso-credentials secret) +# SSO_ENABLED=true +# SSO_ISSUER_URL=http://keycloak-service:8080/realms/ambient-code +# SSO_CLIENT_ID=ambient-frontend +# SSO_CLIENT_SECRET=dev-secret-do-not-use-in-prod +# SSO_REDIRECT_URI=http://localhost:11646/api/auth/sso/callback +# SESSION_SECRET=dev-session-secret-must-be-at-least-32-chars-long + +# Legacy: OpenShift identity for local development (when SSO is off) +# OC_TOKEN= +# OC_USER= +# OC_EMAIL= +# ENABLE_OC_WHOAMI=1 # File upload size limits (in bytes) # These control the maximum file sizes allowed for different file types diff --git a/components/frontend/README.md b/components/frontend/README.md index 50d4f3f1f..ce8aad67b 100644 --- a/components/frontend/README.md +++ b/components/frontend/README.md @@ -75,42 +75,28 @@ npm run lint - Run `npm run build` - must pass with 0 errors, 0 warnings - See `DESIGN_GUIDELINES.md` for comprehensive frontend development standards -### Header forwarding model (dev and prod) -Next.js API routes forward incoming headers to the backend. They do not auto-inject user identity. In development, you can optionally provide values via environment or `oc`: +### Authentication model -- Forwarded when present on the request: - - `X-Forwarded-User`, `X-Forwarded-Email`, `X-Forwarded-Preferred-Username` - - `X-Forwarded-Groups` - - `X-OpenShift-Project` - - `Authorization: Bearer ` (forwarded as `X-Forwarded-Access-Token`) -- Optional dev helpers: - - `OC_USER`, `OC_EMAIL`, `OC_TOKEN` - - `ENABLE_OC_WHOAMI=1` to let the server call `oc whoami` / `oc whoami -t` +The frontend acts as a BFF (Backend-for-Frontend) OIDC confidential client. Users authenticate via Keycloak, and the frontend stores the OIDC session in an encrypted httpOnly cookie. On each API request, the frontend extracts the JWT from the session and forwards it as `Authorization: Bearer ` to the backend. -In production, put an OAuth/ingress proxy in front of the app to set these headers. +In the Kind dev cluster, Keycloak is deployed automatically with `make kind-up`. Log in with `developer` / `developer`. + +Legacy mode (when `SSO_ENABLED` is not set): the frontend falls back to forwarding `X-Forwarded-*` headers from an OAuth proxy sidecar. ### Environment variables -- `BACKEND_URL` (default: `http://localhost:8080/api`) - - Used by server-side API routes to reach the backend. -- `FEEDBACK_URL` (optional) - - URL for the feedback link in the masthead. If not set, the link will not appear. -- `GITHUB_APP_SLUG` (required for GitHub integration) - - The slug of the GitHub App (e.g. `ambient-code`). Without this, the Connect button on the Integrations page is disabled. -- `GITHUB_CALLBACK_URL` (optional) - - Explicit callback URL for GitHub App OAuth. Used when multiple clusters share one GitHub App. Falls back to `/api/auth/github/user/callback`. Must be registered as a callback URL in the GitHub App settings. Set per-cluster via CI workflow (`oc set env`). -- Optional dev helpers: `OC_USER`, `OC_EMAIL`, `OC_TOKEN`, `ENABLE_OC_WHOAMI=1` - -You can also put these in a `.env.local` file in this folder: -``` -BACKEND_URL=http://localhost:8080/api -# Optional: URL for feedback link in masthead -# FEEDBACK_URL=https://forms.example.com/feedback -# Optional dev helpers -# OC_USER=your.name -# OC_EMAIL=your.name@example.com -# OC_TOKEN=... -# ENABLE_OC_WHOAMI=1 -``` +- `BACKEND_URL` (default: `http://localhost:8080/api`) — backend API for server-side routes +- `FEEDBACK_URL` (optional) — feedback link in the masthead +- `GITHUB_APP_SLUG` (required for GitHub integration) — GitHub App slug +- `GITHUB_CALLBACK_URL` (optional) — explicit GitHub OAuth callback URL +- `SSO_ISSUER_URL` — Keycloak OIDC issuer URL (e.g. `http://keycloak-service:8080/realms/ambient-code`) +- `SSO_CLIENT_ID` — OIDC confidential client ID (e.g. `ambient-frontend`) +- `SSO_CLIENT_SECRET` — OIDC client secret +- `SSO_ENABLED` — set to `true` to enable SSO auth (disables OAuth proxy header forwarding) +- `SSO_REDIRECT_URI` — OIDC callback URL (e.g. `http://localhost:11646/api/auth/sso/callback`) +- `SESSION_SECRET` — encryption key for the session cookie (min 32 chars) +- `SSO_PUBLIC_ISSUER_URL` (Kind only) — public Keycloak URL when it differs from `SSO_ISSUER_URL` + +Legacy dev helpers (when SSO is off): `OC_USER`, `OC_EMAIL`, `OC_TOKEN`, `ENABLE_OC_WHOAMI=1` ### Verifying requests Backend directly (requires headers): diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json index 6e048a6fe..5ec120817 100644 --- a/components/frontend/package-lock.json +++ b/components/frontend/package-lock.json @@ -38,10 +38,13 @@ "file-type": "^21.3.2", "geist": "^1.7.0", "highlight.js": "^11.11.1", + "iron-session": "^8.0.4", + "jose": "^6.2.3", "lucide-react": "^0.542.0", "marked": "^17.0.4", "next": "16.2.6", "next-themes": "^0.4.6", + "openid-client": "^6.8.4", "python-struct": "^1.1.3", "radix-ui": "^1.4.3", "react": "^19.1.0", @@ -6530,6 +6533,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cron-parser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", @@ -8411,6 +8423,30 @@ "node": ">= 0.4" } }, + "node_modules/iron-session": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz", + "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==", + "funding": [ + "https://github.com/sponsors/vvo", + "https://github.com/sponsors/brc-dd" + ], + "license": "MIT", + "dependencies": { + "cookie": "^0.7.2", + "iron-webcrypto": "^1.2.1", + "uncrypto": "^0.1.3" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -8980,6 +9016,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11039,6 +11084,15 @@ "dev": true, "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11183,6 +11237,19 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz", + "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -13167,6 +13234,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", diff --git a/components/frontend/package.json b/components/frontend/package.json index 4c3dcff59..b79a07327 100644 --- a/components/frontend/package.json +++ b/components/frontend/package.json @@ -42,10 +42,13 @@ "file-type": "^21.3.2", "geist": "^1.7.0", "highlight.js": "^11.11.1", + "iron-session": "^8.0.4", + "jose": "^6.2.3", "lucide-react": "^0.542.0", "marked": "^17.0.4", "next": "16.2.6", "next-themes": "^0.4.6", + "openid-client": "^6.8.4", "python-struct": "^1.1.3", "radix-ui": "^1.4.3", "react": "^19.1.0", diff --git a/components/frontend/src/app/api/auth/sso/callback/route.ts b/components/frontend/src/app/api/auth/sso/callback/route.ts new file mode 100644 index 000000000..5250f13c2 --- /dev/null +++ b/components/frontend/src/app/api/auth/sso/callback/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { exchangeCode } from "@/lib/oidc"; +import { getSession } from "@/lib/session"; + +export async function GET(request: NextRequest) { + const cookieStore = await cookies(); + const codeVerifier = cookieStore.get("oidc_code_verifier")?.value; + const expectedState = cookieStore.get("oidc_state")?.value; + const returnTo = cookieStore.get("oidc_return_to")?.value || "/"; + + if (!codeVerifier || !expectedState) { + const loginUrl = new URL("/api/auth/sso/login", request.url); + loginUrl.searchParams.set("returnTo", returnTo); + return NextResponse.redirect(loginUrl); + } + + try { + const incomingUrl = new URL(request.url); + const baseRedirectUri = process.env.SSO_REDIRECT_URI || `${incomingUrl.origin}/api/auth/sso/callback`; + const callbackUrl = new URL(baseRedirectUri); + incomingUrl.searchParams.forEach((value, key) => { + callbackUrl.searchParams.set(key, value); + }); + + const tokens = await exchangeCode(callbackUrl, codeVerifier, expectedState); + const session = await getSession(); + session.accessToken = tokens.accessToken; + session.refreshToken = tokens.refreshToken; + session.idToken = tokens.idToken; + session.expiresAt = tokens.expiresAt; + await session.save(); + + cookieStore.delete("oidc_code_verifier"); + cookieStore.delete("oidc_state"); + cookieStore.delete("oidc_return_to"); + + const origin = process.env.SSO_REDIRECT_URI + ? new URL(process.env.SSO_REDIRECT_URI).origin + : request.nextUrl.origin; + return NextResponse.redirect(new URL(returnTo, origin)); + } catch (err) { + console.error("OIDC callback failed:", err instanceof Error ? err.message : err); + cookieStore.delete("oidc_code_verifier"); + cookieStore.delete("oidc_state"); + cookieStore.delete("oidc_return_to"); + const loginUrl = new URL("/api/auth/sso/login", request.url); + loginUrl.searchParams.set("returnTo", returnTo); + return NextResponse.redirect(loginUrl); + } +} diff --git a/components/frontend/src/app/api/auth/sso/e2e-login/route.ts b/components/frontend/src/app/api/auth/sso/e2e-login/route.ts new file mode 100644 index 000000000..3ca34435f --- /dev/null +++ b/components/frontend/src/app/api/auth/sso/e2e-login/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + // Gate on E2E_TEST_HELPERS rather than NODE_ENV so this route works in + // Kind/CI where the Docker image sets NODE_ENV=production but E2E tests + // still need programmatic session creation. + if (process.env.E2E_TEST_HELPERS !== "true") { + return NextResponse.json({ error: "Not available" }, { status: 404 }); + } + + const { token } = await request.json() as { token: string }; + if (!token) { + return NextResponse.json({ error: "Token required" }, { status: 400 }); + } + + const session = await getSession(); + session.accessToken = token; + session.refreshToken = ""; + session.idToken = ""; + session.expiresAt = Math.floor(Date.now() / 1000) + 86400; + await session.save(); + + return NextResponse.json({ ok: true }); +} diff --git a/components/frontend/src/app/api/auth/sso/login/route.ts b/components/frontend/src/app/api/auth/sso/login/route.ts new file mode 100644 index 000000000..ac590ca0e --- /dev/null +++ b/components/frontend/src/app/api/auth/sso/login/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildAuthorizationUrl } from "@/lib/oidc"; + +export async function GET(request: NextRequest) { + const redirectUri = process.env.SSO_REDIRECT_URI + || `${request.nextUrl.origin}/api/auth/sso/callback`; + const returnTo = request.nextUrl.searchParams.get("returnTo") || "/"; + + const { url, codeVerifier, state } = await buildAuthorizationUrl(redirectUri); + + const response = NextResponse.redirect(url); + const cookieOpts = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/", + maxAge: 600, + }; + response.cookies.set("oidc_code_verifier", codeVerifier, cookieOpts); + response.cookies.set("oidc_state", state, cookieOpts); + response.cookies.set("oidc_return_to", returnTo, cookieOpts); + + return response; +} diff --git a/components/frontend/src/app/api/auth/sso/logout/route.ts b/components/frontend/src/app/api/auth/sso/logout/route.ts new file mode 100644 index 000000000..f8b625e5e --- /dev/null +++ b/components/frontend/src/app/api/auth/sso/logout/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/session"; +import { getEndSessionUrl } from "@/lib/oidc"; + +export async function GET(request: NextRequest) { + const session = await getSession(); + const idToken = session.idToken || ""; + session.destroy(); + + const postLogoutRedirectUri = process.env.SSO_REDIRECT_URI + ? new URL(process.env.SSO_REDIRECT_URI).origin + : request.nextUrl.origin; + + if (process.env.SSO_ISSUER_URL && idToken) { + const endSessionUrl = await getEndSessionUrl(idToken, postLogoutRedirectUri); + return NextResponse.redirect(endSessionUrl); + } + + return NextResponse.redirect(postLogoutRedirectUri); +} diff --git a/components/frontend/src/app/api/me/route.ts b/components/frontend/src/app/api/me/route.ts index afbffca0e..257dad176 100644 --- a/components/frontend/src/app/api/me/route.ts +++ b/components/frontend/src/app/api/me/route.ts @@ -7,7 +7,7 @@ export async function GET(request: Request) { const userId = headers['X-Forwarded-User'] || ''; const email = headers['X-Forwarded-Email'] || ''; const username = headers['X-Forwarded-Preferred-Username'] || ''; - const token = headers['X-Forwarded-Access-Token'] || ''; + const token = headers['X-Forwarded-Access-Token'] || headers['Authorization'] || ''; if (!userId && !username && !email && !token) { return Response.json({ authenticated: false }, { status: 200 }); @@ -23,6 +23,7 @@ export async function GET(request: Request) { email, username, displayName, + ssoEnabled: process.env.SSO_ENABLED === 'true', }); } catch (error) { console.error('Error reading user headers:', error); diff --git a/components/frontend/src/app/projects/[name]/layout.tsx b/components/frontend/src/app/projects/[name]/layout.tsx index 341724771..76e34e52f 100755 --- a/components/frontend/src/app/projects/[name]/layout.tsx +++ b/components/frontend/src/app/projects/[name]/layout.tsx @@ -5,6 +5,7 @@ import { useParams, useRouter, usePathname } from "next/navigation"; import { PanelLeft, Plug, LogOut, Menu } from "lucide-react"; import Link from "next/link"; import { useVersion } from "@/services/queries/use-version"; +import { useCurrentUser } from "@/services/queries"; import { DropdownMenu, DropdownMenuContent, @@ -49,9 +50,14 @@ export default function ProjectLayout({ ); const sidebarResize = useResizePanel("session-sidebar-width", 280, 220, 450, "left"); const { data: version } = useVersion(); + const { data: me } = useCurrentUser(); const handleLogout = () => { - window.location.href = '/oauth/sign_out'; + if (me?.ssoEnabled) { + window.location.href = '/api/auth/sso/logout'; + } else { + window.location.href = '/oauth/sign_out'; + } }; // Persist last visited project for redirect on next visit diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index 16efd041d..31357837c 100755 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -1565,11 +1565,12 @@ export default function ProjectSessionDetailPage({ }, [removeFileMutation]); // Keep task tab status badges in sync with live AG-UI state + const updateTaskStatus = fileTabs.updateTaskStatus; useEffect(() => { for (const [taskId, task] of aguiState.backgroundTasks) { - fileTabs.updateTaskStatus(taskId, task.status); + updateTaskStatus(taskId, task.status); } - }, [aguiState.backgroundTasks, fileTabs.updateTaskStatus]); + }, [aguiState.backgroundTasks, updateTaskStatus]); // Loading state if (isLoading || !projectName || !sessionName) { diff --git a/components/frontend/src/app/sso/[...path]/route.ts b/components/frontend/src/app/sso/[...path]/route.ts new file mode 100644 index 000000000..c9115570d --- /dev/null +++ b/components/frontend/src/app/sso/[...path]/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; + +const SSO_ORIGIN = process.env.SSO_ISSUER_URL + ? new URL(process.env.SSO_ISSUER_URL).origin + : null; + +export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return proxyToKeycloak(request, await params); +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return proxyToKeycloak(request, await params); +} + +async function proxyToKeycloak(request: NextRequest, params: { path: string[] }) { + if (!SSO_ORIGIN) { + return NextResponse.json({ error: "SSO not configured" }, { status: 503 }); + } + + const path = params.path.join("/"); + const target = new URL(`/${path}`, SSO_ORIGIN); + target.search = request.nextUrl.search; + + const headers = new Headers(); + for (const [key, value] of request.headers.entries()) { + if (key === "host" || key === "connection") continue; + headers.set(key, value); + } + headers.set("host", target.host); + + const resp = await fetch(target.href, { + method: request.method, + headers, + body: request.method !== "GET" ? await request.text() : undefined, + redirect: "manual", + }); + + const responseHeaders = new Headers(); + for (const [key, value] of resp.headers.entries()) { + if (key === "transfer-encoding") continue; + // Rewrite Location headers from internal to proxy URL + if (key === "location" && SSO_ORIGIN) { + responseHeaders.set(key, value.replace(SSO_ORIGIN, request.nextUrl.origin + "/sso")); + } else { + responseHeaders.set(key, value); + } + } + + return new NextResponse(resp.body, { + status: resp.status, + headers: responseHeaders, + }); +} diff --git a/components/frontend/src/components/navigation.tsx b/components/frontend/src/components/navigation.tsx index 7902c1aa4..f8b9c6e3e 100644 --- a/components/frontend/src/components/navigation.tsx +++ b/components/frontend/src/components/navigation.tsx @@ -11,6 +11,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetClose } from "@/comp import { Button } from "@/components/ui/button"; import { Plug, LogOut, Menu, Home, MessageSquare } from "lucide-react"; import { useVersion } from "@/services/queries/use-version"; +import { useCurrentUser } from "@/services/queries"; import { useIsMobile } from "@/hooks/use-mobile"; type NavigationProps = { @@ -22,13 +23,16 @@ export function Navigation({ feedbackUrl }: NavigationProps) { // const segments = pathname?.split("/").filter(Boolean) || []; const router = useRouter(); const { data: version } = useVersion(); + const { data: me } = useCurrentUser(); const isMobile = useIsMobile(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const handleLogout = () => { - // Redirect to oauth-proxy logout endpoint - // This clears the OpenShift OAuth session and redirects back to login - window.location.href = '/oauth/sign_out'; + if (me?.ssoEnabled) { + window.location.href = '/api/auth/sso/logout'; + } else { + window.location.href = '/oauth/sign_out'; + } }; const handleMobileNav = (path: string) => { diff --git a/components/frontend/src/components/providers/query-provider.tsx b/components/frontend/src/components/providers/query-provider.tsx index cd506b33f..75fb158cd 100644 --- a/components/frontend/src/components/providers/query-provider.tsx +++ b/components/frontend/src/components/providers/query-provider.tsx @@ -2,25 +2,35 @@ /** * React Query Provider - * Wraps the app with QueryClientProvider for data fetching + * Wraps the app with QueryClientProvider for data fetching. + * Includes global 401 detection and session expired dialog. */ import { QueryClientProvider } from '@tanstack/react-query'; -import { getQueryClient } from '@/lib/query-client'; -import { useState } from 'react'; +import { getQueryClient, onSessionExpired } from '@/lib/query-client'; +import { SessionExpiredDialog } from '@/components/session-expired-dialog'; +import { useState, useEffect, useCallback } from 'react'; type QueryProviderProps = { children: React.ReactNode; }; export function QueryProvider({ children }: QueryProviderProps) { - // Create a client instance per request to avoid sharing state between users const [queryClient] = useState(() => getQueryClient()); + const [sessionExpired, setSessionExpired] = useState(false); + + const handleSessionExpired = useCallback(() => { + setSessionExpired(true); + }, []); + + useEffect(() => { + onSessionExpired(handleSessionExpired); + }, [handleSessionExpired]); return ( {children} - {/* */} + ); } diff --git a/components/frontend/src/components/session-expired-dialog.tsx b/components/frontend/src/components/session-expired-dialog.tsx new file mode 100644 index 000000000..d515f19b5 --- /dev/null +++ b/components/frontend/src/components/session-expired-dialog.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { LogIn } from "lucide-react"; + +type SessionExpiredDialogProps = { + open: boolean; +}; + +export function SessionExpiredDialog({ open }: SessionExpiredDialogProps) { + const handleLogin = () => { + const returnTo = window.location.pathname + window.location.search; + window.location.href = `/api/auth/sso/login?returnTo=${encodeURIComponent(returnTo)}`; + }; + + return ( + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + Session expired + + Your session has expired. Any monitored sessions will resume when you + log back in. + + + + + + + + ); +} diff --git a/components/frontend/src/lib/auth.ts b/components/frontend/src/lib/auth.ts index 59d13891b..dc0901269 100644 --- a/components/frontend/src/lib/auth.ts +++ b/components/frontend/src/lib/auth.ts @@ -119,6 +119,11 @@ export function buildForwardHeaders(request: Request, extra?: Record): Promise { + // SSO path: extract JWT from session cookie and forward to backend + if (process.env.SSO_ENABLED === 'true') { + return buildForwardHeadersSSO(request, extra); + } + const headers = buildForwardHeaders(request, extra); // Local development mode: inject mock user when DISABLE_AUTH is true @@ -158,3 +163,46 @@ export async function buildForwardHeadersAsync(request: Request, extra?: Record< return headers; } + +async function buildForwardHeadersSSO(request: Request, extra?: Record): Promise { + const { getAccessToken } = await import('./session'); + const { decodeJwt } = await import('jose'); + + const headers: ForwardHeaders = { + 'Accept': 'application/json', + }; + + // Try session token first (browser users with SSO cookie) + let accessToken = await getAccessToken(); + + // Fall back to Bearer token from request (E2E tests, API clients, service accounts) + // This enables dual-auth: SSO sessions + direct Bearer token authentication + if (!accessToken) { + accessToken = extractAccessToken(request) || undefined; + } + + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + + try { + const claims = decodeJwt(accessToken); + if (claims.email) headers['X-Forwarded-Email'] = String(claims.email); + if (claims.preferred_username) headers['X-Forwarded-Preferred-Username'] = String(claims.preferred_username); + if (claims.sub) headers['X-Forwarded-User'] = String(claims.sub); + if (Array.isArray(claims.groups)) headers['X-Forwarded-Groups'] = claims.groups.join(','); + } catch { + // Backend extracts identity from its own JWT validation + } + } + + const project = request.headers.get('X-OpenShift-Project'); + if (project) headers['X-OpenShift-Project'] = project; + + if (extra) { + for (const [k, v] of Object.entries(extra)) { + if (v !== undefined && v !== null) headers[k] = String(v); + } + } + + return headers; +} diff --git a/components/frontend/src/lib/env.ts b/components/frontend/src/lib/env.ts index e0a311e57..77254e451 100644 --- a/components/frontend/src/lib/env.ts +++ b/components/frontend/src/lib/env.ts @@ -27,6 +27,13 @@ type EnvConfig = { OC_EMAIL?: string; ENABLE_OC_WHOAMI?: boolean; + // SSO/OIDC configuration (server-side only, optional) + SSO_ISSUER_URL?: string; + SSO_CLIENT_ID?: string; + SSO_CLIENT_SECRET?: string; + SSO_ENABLED?: boolean; + SESSION_SECRET?: string; + // Unleash feature flags (server-side only, optional) UNLEASH_URL?: string; UNLEASH_CLIENT_KEY?: string; @@ -74,6 +81,11 @@ export const env: EnvConfig = { OC_USER: getOptionalEnv('OC_USER'), OC_EMAIL: getOptionalEnv('OC_EMAIL'), ENABLE_OC_WHOAMI: getBooleanEnv('ENABLE_OC_WHOAMI', false), + SSO_ISSUER_URL: getOptionalEnv('SSO_ISSUER_URL'), + SSO_CLIENT_ID: getOptionalEnv('SSO_CLIENT_ID'), + SSO_CLIENT_SECRET: getOptionalEnv('SSO_CLIENT_SECRET'), + SSO_ENABLED: getBooleanEnv('SSO_ENABLED', false), + SESSION_SECRET: getOptionalEnv('SESSION_SECRET'), UNLEASH_URL: getOptionalEnv('UNLEASH_URL'), UNLEASH_CLIENT_KEY: getOptionalEnv('UNLEASH_CLIENT_KEY'), UNLEASH_APP_NAME: getOptionalEnv('UNLEASH_APP_NAME') || 'ambient-code-platform', diff --git a/components/frontend/src/lib/oidc.ts b/components/frontend/src/lib/oidc.ts new file mode 100644 index 000000000..c6e149c67 --- /dev/null +++ b/components/frontend/src/lib/oidc.ts @@ -0,0 +1,122 @@ +import * as client from "openid-client"; + +const DISCOVERY_TTL_MS = 5 * 60 * 1000; + +let cachedConfig: client.Configuration | null = null; +let cachedAt = 0; + +async function getOIDCConfig(): Promise { + if (cachedConfig && Date.now() - cachedAt < DISCOVERY_TTL_MS) { + return cachedConfig; + } + + const issuerURL = process.env.SSO_ISSUER_URL; + const clientId = process.env.SSO_CLIENT_ID; + const clientSecret = process.env.SSO_CLIENT_SECRET; + + if (!issuerURL || !clientId || !clientSecret) { + throw new Error("SSO_ISSUER_URL, SSO_CLIENT_ID, and SSO_CLIENT_SECRET must be set"); + } + + const serverUrl = new URL(issuerURL); + const useInsecure = serverUrl.protocol === "http:"; + + // With hostname-backchannel-dynamic, the discovery response's issuer + // (public URL) differs from the fetch URL (internal). openid-client v6 + // rejects this mismatch. Fetch discovery manually and construct config. + // See: https://github.com/panva/openid-client/issues/737 + const wellKnownUrl = `${issuerURL}/.well-known/openid-configuration`; + const resp = await fetch(wellKnownUrl); + if (!resp.ok) { + throw new Error(`OIDC discovery failed: ${resp.status}`); + } + const metadata = await resp.json(); + + cachedConfig = new client.Configuration( + metadata as client.ServerMetadata, + clientId, + clientSecret, + ); + + if (useInsecure) { + client.allowInsecureRequests(cachedConfig); + } + + cachedAt = Date.now(); + return cachedConfig; +} + +export async function buildAuthorizationUrl(redirectUri: string): Promise<{ + url: string; + codeVerifier: string; + state: string; +}> { + const config = await getOIDCConfig(); + const codeVerifier = client.randomPKCECodeVerifier(); + const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); + const state = client.randomState(); + + const redirectTo = client.buildAuthorizationUrl(config, { + redirect_uri: redirectUri, + scope: "openid", + code_challenge: codeChallenge, + code_challenge_method: "S256", + state, + }); + + return { url: redirectTo.href, codeVerifier, state }; +} + +export async function exchangeCode( + callbackUrl: URL, + codeVerifier: string, + expectedState: string, +): Promise<{ + accessToken: string; + refreshToken: string; + idToken: string; + expiresAt: number; +}> { + const config = await getOIDCConfig(); + const tokens = await client.authorizationCodeGrant(config, callbackUrl, { + pkceCodeVerifier: codeVerifier, + expectedState, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? "", + idToken: tokens.id_token ?? "", + expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 300), + }; +} + +export async function refreshOIDCTokens(refreshToken: string): Promise<{ + accessToken: string; + refreshToken: string; + idToken: string; + expiresAt: number; +}> { + const config = await getOIDCConfig(); + const tokens = await client.refreshTokenGrant(config, refreshToken); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? refreshToken, + idToken: tokens.id_token ?? "", + expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 300), + }; +} + +export async function getEndSessionUrl(idTokenHint: string, postLogoutRedirectUri: string): Promise { + const config = await getOIDCConfig(); + const metadata = config.serverMetadata(); + const endSessionEndpoint = metadata.end_session_endpoint; + if (!endSessionEndpoint) { + return postLogoutRedirectUri; + } + const url = new URL(String(endSessionEndpoint)); + url.searchParams.set("id_token_hint", idTokenHint); + url.searchParams.set("post_logout_redirect_uri", postLogoutRedirectUri); + return url.href; +} diff --git a/components/frontend/src/lib/query-client.ts b/components/frontend/src/lib/query-client.ts index 5203f72e4..c094a08f1 100644 --- a/components/frontend/src/lib/query-client.ts +++ b/components/frontend/src/lib/query-client.ts @@ -2,7 +2,25 @@ * React Query client configuration */ -import { QueryClient, DefaultOptions } from '@tanstack/react-query'; +import { QueryClient, DefaultOptions, QueryCache, MutationCache } from '@tanstack/react-query'; +import { ApiClientError } from '@/types/api/common'; + +let sessionExpiredCallback: (() => void) | null = null; + +export function onSessionExpired(cb: () => void) { + sessionExpiredCallback = cb; +} + +function handleError(error: unknown) { + if (error instanceof ApiClientError && error.code === '401') { + sessionExpiredCallback?.(); + } +} + +function shouldRetry(failureCount: number, error: unknown): boolean { + if (error instanceof ApiClientError && error.code === '401') return false; + return failureCount < 1; +} const queryConfig: DefaultOptions = { queries: { @@ -12,8 +30,7 @@ const queryConfig: DefaultOptions = { // Cache time: 10 minutes - unused data is garbage collected after 10 minutes gcTime: 10 * 60 * 1000, - // Retry failed requests once - retry: 1, + retry: shouldRetry, // Retry delay with exponential backoff retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), @@ -25,8 +42,7 @@ const queryConfig: DefaultOptions = { refetchOnMount: false, }, mutations: { - // Retry mutations once - retry: 1, + retry: shouldRetry, }, }; @@ -37,6 +53,8 @@ const queryConfig: DefaultOptions = { export function makeQueryClient() { return new QueryClient({ defaultOptions: queryConfig, + queryCache: new QueryCache({ onError: handleError }), + mutationCache: new MutationCache({ onError: handleError }), }); } diff --git a/components/frontend/src/lib/session.ts b/components/frontend/src/lib/session.ts new file mode 100644 index 000000000..e9727c81f --- /dev/null +++ b/components/frontend/src/lib/session.ts @@ -0,0 +1,55 @@ +import { getIronSession, type SessionOptions } from "iron-session"; +import { cookies } from "next/headers"; + +export interface SessionData { + accessToken: string; + refreshToken: string; + idToken: string; + expiresAt: number; +} + +const sessionOptions: SessionOptions = { + password: process.env.SESSION_SECRET || "dev-session-secret-must-be-at-least-32-chars-long", + cookieName: "ambient-session", + cookieOptions: { + secure: process.env.NODE_ENV === "production", + httpOnly: true, + sameSite: "lax" as const, + path: "/", + }, +}; + +export async function getSession() { + return getIronSession(await cookies(), sessionOptions); +} + +export async function getAccessToken(): Promise { + const session = await getSession(); + if (!session.accessToken) return undefined; + + if (Date.now() / 1000 < session.expiresAt - 60) { + return session.accessToken; + } + + if (!session.refreshToken) { + session.destroy(); + return undefined; + } + + try { + console.log("SSO: refreshing access token (expired at", new Date(session.expiresAt * 1000).toISOString(), ")"); + const { refreshOIDCTokens } = await import("./oidc"); + const tokens = await refreshOIDCTokens(session.refreshToken); + session.accessToken = tokens.accessToken; + session.refreshToken = tokens.refreshToken; + session.idToken = tokens.idToken; + session.expiresAt = tokens.expiresAt; + await session.save(); + console.log("SSO: token refreshed, new expiry", new Date(tokens.expiresAt * 1000).toISOString()); + return session.accessToken; + } catch (err) { + console.error("SSO: token refresh failed, destroying session:", err instanceof Error ? err.message : err); + session.destroy(); + return undefined; + } +} diff --git a/components/frontend/src/middleware.ts b/components/frontend/src/middleware.ts new file mode 100644 index 000000000..0d19e2ab7 --- /dev/null +++ b/components/frontend/src/middleware.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; + +export function middleware(request: NextRequest) { + if (process.env.SSO_ENABLED !== "true") { + return NextResponse.next(); + } + + const sessionCookie = request.cookies.get("ambient-session"); + if (sessionCookie) { + return NextResponse.next(); + } + + // RSC/fetch requests can't follow cross-origin redirects to Keycloak. + // Return 401 so the client-side SessionExpiredDialog handles it. + const isRSC = request.headers.get("rsc") === "1" + || request.headers.get("next-router-state-tree") !== null; + const isFetch = request.headers.get("accept")?.includes("application/json") + || request.headers.get("x-requested-with") === "XMLHttpRequest"; + + if (isRSC || isFetch) { + return NextResponse.json( + { error: "Session expired" }, + { status: 401 }, + ); + } + + const loginUrl = new URL("/api/auth/sso/login", request.url); + loginUrl.searchParams.set("returnTo", request.nextUrl.pathname); + return NextResponse.redirect(loginUrl); +} + +export const config = { + matcher: [ + "/((?!api|sso|_next|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map)).*)", + ], +}; diff --git a/components/frontend/src/services/api/auth.ts b/components/frontend/src/services/api/auth.ts index a7e0890cb..12c5810fd 100644 --- a/components/frontend/src/services/api/auth.ts +++ b/components/frontend/src/services/api/auth.ts @@ -10,6 +10,7 @@ export type UserProfile = { email?: string; username?: string; displayName?: string; + ssoEnabled?: boolean; }; /** diff --git a/components/manifests/base/rbac/backend-clusterrole.yaml b/components/manifests/base/rbac/backend-clusterrole.yaml index ec61aec45..2a9044308 100644 --- a/components/manifests/base/rbac/backend-clusterrole.yaml +++ b/components/manifests/base/rbac/backend-clusterrole.yaml @@ -91,3 +91,8 @@ rules: - apiGroups: ["authorization.k8s.io"] resources: ["subjectaccessreviews", "selfsubjectaccessreviews"] verbs: ["create"] + +# User/group impersonation for SSO JWT-authenticated requests +- apiGroups: [""] + resources: ["users", "groups", "serviceaccounts"] + verbs: ["impersonate"] diff --git a/components/manifests/overlays/kind/api-server-security-patch.yaml b/components/manifests/overlays/kind/api-server-security-patch.yaml new file mode 100644 index 000000000..5d5b37e2a --- /dev/null +++ b/components/manifests/overlays/kind/api-server-security-patch.yaml @@ -0,0 +1,9 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ambient-api-server +spec: + template: + spec: + securityContext: + runAsNonRoot: false diff --git a/components/manifests/overlays/kind/backend-sso-patch.yaml b/components/manifests/overlays/kind/backend-sso-patch.yaml new file mode 100644 index 000000000..a236f5202 --- /dev/null +++ b/components/manifests/overlays/kind/backend-sso-patch.yaml @@ -0,0 +1,24 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend-api +spec: + template: + spec: + containers: + - name: backend-api + env: + - name: SSO_ISSUER_URL + valueFrom: + secretKeyRef: + name: sso-credentials + key: SSO_ISSUER_URL + optional: true + - name: SSO_AUDIENCE + valueFrom: + secretKeyRef: + name: sso-credentials + key: SSO_AUDIENCE + optional: true + - name: SSO_PUBLIC_ISSUER_URL + value: "http://localhost/sso/realms/ambient-code" diff --git a/components/manifests/overlays/kind/control-plane-env-patch.yaml b/components/manifests/overlays/kind/control-plane-env-patch.yaml new file mode 100644 index 000000000..56ffb30c6 --- /dev/null +++ b/components/manifests/overlays/kind/control-plane-env-patch.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ambient-control-plane +spec: + template: + spec: + containers: + - name: ambient-control-plane + env: + - name: OIDC_CLIENT_ID + valueFrom: + secretKeyRef: + name: ambient-api-server + key: clientId + optional: true + - name: OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: ambient-api-server + key: clientSecret + optional: true + - name: AMBIENT_API_SERVER_URL + value: "http://ambient-api-server.ambient-code.svc:8000" + - name: AMBIENT_GRPC_USE_TLS + value: "false" diff --git a/components/manifests/overlays/kind/e2e-rolebinding.yaml b/components/manifests/overlays/kind/e2e-rolebinding.yaml new file mode 100644 index 000000000..680420118 --- /dev/null +++ b/components/manifests/overlays/kind/e2e-rolebinding.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: e2e-test-admin + labels: + app: ambient-e2e +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: ambient-project-admin +subjects: + - kind: User + name: service-account-ambient-e2e + apiGroup: rbac.authorization.k8s.io diff --git a/components/manifests/overlays/kind/frontend-test-patch.yaml b/components/manifests/overlays/kind/frontend-test-patch.yaml index a8fc76276..b4bff05f3 100644 --- a/components/manifests/overlays/kind/frontend-test-patch.yaml +++ b/components/manifests/overlays/kind/frontend-test-patch.yaml @@ -31,3 +31,39 @@ spec: value: "system:serviceaccount:ambient-code:test-user" - name: OC_EMAIL value: "test-user@vteam.local" + # Enable E2E test helper routes (e.g. /api/auth/sso/e2e-login) + - name: E2E_TEST_HELPERS + value: "true" + # SSO/OIDC configuration (disabled by default; enable with make kind-sso-toggle) + - name: SSO_ENABLED + value: "false" + - name: NEXT_PUBLIC_SSO_ENABLED + value: "false" + - name: SSO_ISSUER_URL + valueFrom: + secretKeyRef: + name: sso-credentials + key: SSO_ISSUER_URL + optional: true + - name: SSO_CLIENT_ID + valueFrom: + secretKeyRef: + name: sso-credentials + key: SSO_CLIENT_ID + optional: true + - name: SSO_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: sso-credentials + key: SSO_CLIENT_SECRET + optional: true + - name: SESSION_SECRET + valueFrom: + secretKeyRef: + name: sso-credentials + key: SESSION_SECRET + optional: true + - name: SSO_REDIRECT_URI + value: "http://localhost/api/auth/sso/callback" + - name: SSO_PUBLIC_ISSUER_URL + value: "http://localhost/sso/realms/ambient-code" diff --git a/components/manifests/overlays/kind/keycloak-deployment.yaml b/components/manifests/overlays/kind/keycloak-deployment.yaml new file mode 100644 index 000000000..00c3af202 --- /dev/null +++ b/components/manifests/overlays/kind/keycloak-deployment.yaml @@ -0,0 +1,87 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak + labels: + app: keycloak +spec: + replicas: 1 + selector: + matchLabels: + app: keycloak + template: + metadata: + labels: + app: keycloak + spec: + securityContext: + runAsNonRoot: false + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:26.0 + args: + - start-dev + - --import-realm + env: + - name: KC_HOSTNAME + value: "http://localhost/sso" + - name: KC_HOSTNAME_BACKCHANNEL_DYNAMIC + value: "true" + - name: KC_PROXY_HEADERS + value: "xforwarded" + - name: KC_HTTP_ENABLED + value: "true" + - name: KEYCLOAK_ADMIN + value: admin + - name: KEYCLOAK_ADMIN_PASSWORD + value: admin + ports: + - containerPort: 8080 + name: http + protocol: TCP + volumeMounts: + - name: realm-config + mountPath: /opt/keycloak/data/import + readOnly: true + resources: + requests: + cpu: 200m + memory: 768Mi + limits: + cpu: "2" + memory: 2Gi + readinessProbe: + httpGet: + path: /realms/ambient-code + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 10 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /realms/ambient-code + port: 8080 + initialDelaySeconds: 120 + periodSeconds: 30 + failureThreshold: 5 + volumes: + - name: realm-config + configMap: + name: keycloak-realm-config +--- +apiVersion: v1 +kind: Service +metadata: + name: keycloak-service + labels: + app: keycloak +spec: + type: NodePort + selector: + app: keycloak + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP + name: http + nodePort: 30090 diff --git a/components/manifests/overlays/kind/keycloak-realm.json b/components/manifests/overlays/kind/keycloak-realm.json new file mode 100644 index 000000000..0c72cc332 --- /dev/null +++ b/components/manifests/overlays/kind/keycloak-realm.json @@ -0,0 +1,235 @@ +{ + "realm": "ambient-code", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "accessTokenLifespan": 300, + "ssoSessionMaxLifespan": 1800, + "clients": [ + { + "clientId": "ambient-frontend", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "dev-secret-do-not-use-in-prod", + "redirectUris": [ + "http://localhost/*", + "http://frontend-service:3000/*", + "http://frontend-service.ambient-code.svc.cluster.local:3000/*" + ], + "attributes": { + "post.logout.redirect.uris": "http://localhost/*##http://frontend-service:3000/*" + }, + "webOrigins": [ + "+" + ], + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "fullScopeAllowed": true, + "defaultClientScopes": [ + "openid", + "email", + "profile", + "groups" + ], + "protocolMappers": [ + { + "name": "audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "ambient-frontend", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "email", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "preferred_username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "username", + "claim.name": "preferred_username", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + }, + { + "clientId": "ambient-cli", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "redirectUris": [ + "http://localhost:*/callback", + "http://127.0.0.1:*/callback" + ], + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "defaultClientScopes": [ + "openid", + "email", + "profile", + "groups" + ] + }, + { + "clientId": "ambient-e2e", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "e2e-secret-do-not-use-in-prod", + "standardFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "fullScopeAllowed": true, + "defaultClientScopes": [ + "openid", + "email", + "profile", + "groups" + ], + "protocolMappers": [ + { + "name": "audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "ambient-frontend", + "access.token.claim": "true" + } + }, + { + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "access.token.claim": "true" + } + }, + { + "name": "preferred_username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "username", + "claim.name": "preferred_username", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + } + ], + "clientScopes": [ + { + "name": "groups", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "false", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "userinfo.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "developer", + "email": "developer@local.dev", + "emailVerified": true, + "firstName": "Dev", + "lastName": "User", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "developer", + "temporary": false + } + ], + "groups": [ + "/ambient-users" + ] + }, + { + "username": "admin", + "email": "admin@local.dev", + "emailVerified": true, + "firstName": "Admin", + "lastName": "User", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "admin", + "temporary": false + } + ], + "groups": [ + "/ambient-users", + "/ambient-admins" + ] + } + ], + "groups": [ + { + "name": "ambient-users" + }, + { + "name": "ambient-admins" + } + ] +} diff --git a/components/manifests/overlays/kind/kustomization.yaml b/components/manifests/overlays/kind/kustomization.yaml index 142d0dcaf..211bb72a4 100644 --- a/components/manifests/overlays/kind/kustomization.yaml +++ b/components/manifests/overlays/kind/kustomization.yaml @@ -18,6 +18,10 @@ resources: - postgresql-init-scripts.yaml - ldap-config.yaml - ldap-credentials.yaml +# SSO: Keycloak for local OIDC authentication +- keycloak-deployment.yaml +- sso-credentials.yaml +- e2e-rolebinding.yaml # Patches for e2e environment patches: @@ -114,6 +118,34 @@ patches: version: v1 kind: Deployment name: postgresql +# API server: relax runAsNonRoot for Kind (upstream image runs as root) +- path: api-server-security-patch.yaml + target: + group: apps + version: v1 + kind: Deployment + name: ambient-api-server +# Backend: SSO issuer URL and audience for JWT validation +- path: backend-sso-patch.yaml + target: + group: apps + version: v1 + kind: Deployment + name: backend-api +# Control plane: make OIDC env vars optional, use HTTP for Kind +- path: control-plane-env-patch.yaml + target: + group: apps + version: v1 + kind: Deployment + name: ambient-control-plane + +configMapGenerator: +- name: keycloak-realm-config + files: + - ambient-code-realm.json=keycloak-realm.json + options: + disableNameSuffixHash: true # Kind overlay: Use Quay.io production images by default # For local development with local images, use overlays/kind-local/ instead diff --git a/components/manifests/overlays/kind/sso-credentials.yaml b/components/manifests/overlays/kind/sso-credentials.yaml new file mode 100644 index 000000000..7553d230b --- /dev/null +++ b/components/manifests/overlays/kind/sso-credentials.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Secret +metadata: + name: sso-credentials + labels: + app: ambient-sso +type: Opaque +stringData: + SSO_ISSUER_URL: "http://keycloak-service:8080/realms/ambient-code" + SSO_FRONTEND_ISSUER_URL: "http://localhost/sso/realms/ambient-code" + SSO_CLIENT_ID: "ambient-frontend" + SSO_CLIENT_SECRET: "dev-secret-do-not-use-in-prod" + SSO_AUDIENCE: "ambient-frontend" + SESSION_SECRET: "dev-session-secret-must-be-at-least-32-chars-long" diff --git a/docs/internal/deployment/OPENSHIFT_OAUTH.md b/docs/internal/deployment/OPENSHIFT_OAUTH.md index f07e315ef..8c0c029d4 100644 --- a/docs/internal/deployment/OPENSHIFT_OAUTH.md +++ b/docs/internal/deployment/OPENSHIFT_OAUTH.md @@ -1,5 +1,7 @@ ## OpenShift OAuth Setup (with oauth-proxy sidecar) +> **Legacy**: This document describes the OAuth proxy sidecar model which is being replaced by SSO/OIDC authentication via Keycloak. The OAuth proxy is still used in deployments where the `sso-authentication` feature flag is off. See [`specs/security/sso-authentication.spec.md`](../../../specs/security/sso-authentication.spec.md) for the new model. + This project secures the frontend using the OpenShift oauth-proxy sidecar. The proxy handles login against the cluster and forwards authenticated requests to the Next.js app. You only need to do two one-time items per cluster: create an OAuthClient and provide its secret to the app. Also ensure the Route host uses your cluster apps domain. diff --git a/docs/internal/developer/local-development/kind.md b/docs/internal/developer/local-development/kind.md index 7e5462b98..d8ff146b7 100755 --- a/docs/internal/developer/local-development/kind.md +++ b/docs/internal/developer/local-development/kind.md @@ -72,14 +72,29 @@ Creates kind cluster and deploys platform with Quay.io images. **What it does:** 1. Creates minimal kind cluster (no ingress) 2. Deploys platform (backend, frontend, operator, minio) -3. Initializes MinIO storage -4. Extracts test token to `e2e/.env.test` +3. Deploys Keycloak with pre-configured realm (`ambient-code`) +4. Initializes MinIO storage +5. Extracts test token to `e2e/.env.test` **Access:** - Run `make kind-port-forward` in another terminal -- Frontend: `http://localhost:8080` -- Backend: `http://localhost:8081` -- Token: `kubectl get secret test-user-token -n ambient-code -o jsonpath='{.data.token}' | base64 -d` +- Frontend: `http://localhost:` (port shown in output) +- Backend: `http://localhost:` + +**Authentication:** +- By default, the cluster starts in **legacy auth mode** (SA token via `OC_TOKEN`) +- To enable SSO/Keycloak authentication: `make kind-sso-toggle` +- Dev credentials (when SSO is on): `developer` / `developer` (or `admin` / `admin`) +- Sessions last 30 minutes; access tokens refresh silently every 5 minutes +- Toggle back to legacy: `make kind-sso-toggle` (it flips both frontend and backend) + +### `make kind-sso-toggle` + +Toggles SSO authentication on/off in the Kind cluster. Affects both the frontend +(`SSO_ENABLED` env var) and the backend (`sso-authentication` Unleash flag). + +- **SSO on**: Frontend redirects to Keycloak login, backend validates JWTs +- **SSO off** (default): Frontend uses `OC_TOKEN` SA token, backend uses raw bearer tokens ### `make test-e2e` diff --git a/e2e/README.md b/e2e/README.md index 7c17dad21..fc47dd27a 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -7,19 +7,18 @@ Cypress E2E tests for the Ambient Code Platform. Tests run against a live cluste ## Quick Start ```bash -# Prerequisites: frontend running (npm run dev), backend port-forwarded +# Prerequisites: Kind cluster running (make kind-up), port-forwarded (make kind-port-forward) cd e2e && npm install +# Extract test token (uses Keycloak client_credentials when available, falls back to K8s SA) +bash scripts/extract-token.sh + # Run headless -TEST_TOKEN=$(kubectl get secret test-user-token -n ambient-code \ - -o jsonpath='{.data.token}' | base64 -d) \ -CYPRESS_BASE_URL=http://localhost:3000 \ +source .env.test npx cypress run --browser chrome --spec "cypress/e2e/sessions.cy.ts" # Interactive mode (for debugging) -TEST_TOKEN=$(kubectl get secret test-user-token -n ambient-code \ - -o jsonpath='{.data.token}' | base64 -d) \ -CYPRESS_BASE_URL=http://localhost:3000 \ +source .env.test npx cypress open ``` diff --git a/e2e/cypress.config.ts b/e2e/cypress.config.ts index 5ac5a0f33..d94647a15 100644 --- a/e2e/cypress.config.ts +++ b/e2e/cypress.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ // CYPRESS_* env vars are automatically exposed, but we explicitly set these too config.env.ANTHROPIC_API_KEY = process.env.CYPRESS_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '' config.env.TEST_TOKEN = process.env.CYPRESS_TEST_TOKEN || process.env.TEST_TOKEN || config.env.TEST_TOKEN || '' + config.env.SSO_MODE = process.env.E2E_USE_SSO === 'true' // Force 1x DPI for screenshot consistency across platforms if (process.env.CYPRESS_SCREENSHOT_MODE) { diff --git a/e2e/cypress/support/commands.ts b/e2e/cypress/support/commands.ts index 13870844f..25e1097b6 100644 --- a/e2e/cypress/support/commands.ts +++ b/e2e/cypress/support/commands.ts @@ -20,20 +20,30 @@ Cypress.Commands.add('setAuthToken', (token: string) => { }).as('authInterceptor') }) -// Add global beforeEach to set up auth -// Note: In e2e environment, NEXT_PUBLIC_E2E_TOKEN is baked into the frontend build -// This intercept is kept as backup for direct backend API calls (if any) +// Set up auth before each test. +// In SSO mode, creates a session cookie via the E2E login route so that +// cy.visit() page navigations pass the middleware without Keycloak redirect. +// Also intercepts all fetch/XHR requests to add the Authorization header. beforeEach(() => { const token = Cypress.env('TEST_TOKEN') - if (token) { - // Intercept all requests and add auth header (backup) - cy.intercept('**', (req) => { - // Only add header if not already present (frontend adds it automatically in e2e) - if (!req.headers['Authorization']) { - req.headers['Authorization'] = `Bearer ${token}` - } + if (!token) return + + // Create SSO session cookie if the frontend is in SSO mode + if (Cypress.env('SSO_MODE')) { + cy.request({ + method: 'POST', + url: '/api/auth/sso/e2e-login', + body: { token }, + failOnStatusCode: false, }) } + + // Intercept all fetch/XHR requests and add auth header + cy.intercept('**', (req) => { + if (!req.headers['Authorization']) { + req.headers['Authorization'] = `Bearer ${token}` + } + }) }) // Prevent TypeScript from reading file as legacy script diff --git a/e2e/scripts/extract-token.sh b/e2e/scripts/extract-token.sh index de17c8e0f..173f6ddf0 100755 --- a/e2e/scripts/extract-token.sh +++ b/e2e/scripts/extract-token.sh @@ -8,22 +8,46 @@ echo "Extracting test user token..." # Cluster name (override via env var for multi-worktree support) KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-ambient-local}" -# Wait for the secret to be populated with a token (max 30 seconds) +# Default: K8s SA token (works in both legacy and SSO mode via TokenReview fallback). +# Set E2E_USE_SSO=true to use Keycloak client_credentials instead. TOKEN="" -for i in {1..15}; do - TOKEN=$(kubectl get secret test-user-token -n ambient-code -o jsonpath='{.data.token}' 2>/dev/null | base64 -d 2>/dev/null || echo "") + +if [ "${E2E_USE_SSO:-false}" = "true" ]; then + KEYCLOAK_URL="http://keycloak-service.ambient-code.svc.cluster.local:8080" + KEYCLOAK_REALM="ambient-code" + E2E_CLIENT_ID="${E2E_CLIENT_ID:-ambient-e2e}" + E2E_CLIENT_SECRET="${E2E_CLIENT_SECRET:-e2e-secret-do-not-use-in-prod}" + + echo " Obtaining Keycloak token via client_credentials..." + RESPONSE=$(kubectl run -n ambient-code e2e-token-fetch --rm -i --restart=Never --quiet \ + --image=curlimages/curl -- sh -c \ + "curl -sf -X POST ${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token \ + -d client_id=${E2E_CLIENT_ID} \ + -d client_secret=${E2E_CLIENT_SECRET} \ + -d grant_type=client_credentials \ + -d scope=openid" 2>/dev/null || echo "") + TOKEN=$(echo "$RESPONSE" | jq -r '.access_token // empty' 2>/dev/null || echo "") if [ -n "$TOKEN" ]; then - echo " Token extracted successfully" - break - fi - if [ $i -eq 15 ]; then - echo "Failed to extract test token after 30 seconds" - echo " The secret may not be ready. Check with:" - echo " kubectl get secret test-user-token -n ambient-code" - exit 1 + echo " Token obtained from Keycloak (client_credentials)" + else + echo " Keycloak token fetch failed, falling back to K8s SA token..." fi - sleep 2 -done +fi + +if [ -z "$TOKEN" ]; then + for i in {1..15}; do + TOKEN=$(kubectl get secret test-user-token -n ambient-code -o jsonpath='{.data.token}' 2>/dev/null | base64 -d 2>/dev/null || echo "") + if [ -n "$TOKEN" ]; then + echo " Token extracted from K8s SA" + break + fi + if [ $i -eq 15 ]; then + echo "Failed to extract test token after 30 seconds" + exit 1 + fi + sleep 2 + done +fi # Detect container engine for port detection CONTAINER_ENGINE="${CONTAINER_ENGINE:-}" diff --git a/e2e/scripts/wait-for-ready.sh b/e2e/scripts/wait-for-ready.sh index 59518b56e..4d9394eca 100755 --- a/e2e/scripts/wait-for-ready.sh +++ b/e2e/scripts/wait-for-ready.sh @@ -34,6 +34,12 @@ kubectl wait --for=condition=available --timeout=300s \ deployment/minio \ -n ambient-code 2>/dev/null || echo "⚠️ MinIO not deployed (S3 persistence disabled)" +# Wait for Keycloak (SSO/OIDC provider - frontend needs it for SSO mode) +echo "⏳ Waiting for keycloak..." +kubectl wait --for=condition=available --timeout=300s \ + deployment/keycloak \ + -n ambient-code 2>/dev/null || echo "⚠️ Keycloak not deployed (SSO disabled)" + echo "" echo "✅ All pods are ready!" echo "" diff --git a/specs/security/sso-authentication.spec.md b/specs/security/sso-authentication.spec.md new file mode 100644 index 000000000..dc99d8535 --- /dev/null +++ b/specs/security/sso-authentication.spec.md @@ -0,0 +1,532 @@ +# SSO Authentication Specification + +## Purpose + +The platform SHALL authenticate all human users via OpenID Connect (OIDC) with Red Hat +SSO and represent user identity as signed JWTs throughout the stack. This +replaces the current model where an OpenShift OAuth proxy sidecar produces opaque tokens +that are forwarded to backends. + +The migration unifies the authentication model: every component that needs to know "who +is this user?" validates a JWT against the SSO issuer's JWKS endpoint — no component +relies on opaque tokens, OAuth proxy headers, or Kubernetes TokenReview for human user +identity. + +## Identity Flow + +``` +Browser ──OIDC session cookie──▸ Next.js (BFF) ──JWT──▸ Backend / API Server + │ │ + │ ├─ Validate JWT (JWKS) + │ ├─ Extract identity (claims) + │ └─ K8s client: SA token + │ + Impersonate-User + │ + Impersonate-Group + ▼ │ + Red Hat SSO K8s API Server + (confidential client) (RBAC as impersonated user) +``` + +## Requirements + +### Requirement: BFF OIDC Session Model + +The frontend SHALL act as an OIDC confidential client using the Authorization Code Flow. +The browser SHALL receive an opaque, httpOnly, secure, SameSite OIDC session cookie — +never a raw JWT. The frontend server SHALL exchange the OIDC session for a JWT when +proxying requests to backend services. + +The OIDC callback route SHALL coexist with existing integration auth routes under +`/api/auth/` (GitHub, GitLab, Jira, Google, Gerrit, CodeRabbit). The OIDC callback +MUST NOT conflict with or disrupt those routes. + +#### Scenario: User login + +- GIVEN a user navigates to the platform +- WHEN they are not authenticated +- THEN the frontend redirects to the SSO authorization endpoint +- AND the SSO login page is displayed + +#### Scenario: OIDC callback + +- GIVEN the user completes SSO authentication +- WHEN SSO redirects to the frontend OIDC callback route +- THEN the frontend exchanges the authorization code for tokens +- AND stores the OIDC session server-side +- AND sets an httpOnly, secure, SameSite cookie on the browser + +#### Scenario: OIDC routes coexist with integration auth routes + +- GIVEN existing integration auth routes at `/api/auth/{provider}/connect`, `/api/auth/{provider}/status`, etc. +- WHEN the OIDC callback route is added +- THEN integration auth routes continue to function unchanged +- AND the OIDC route does not shadow or intercept integration auth requests + +#### Scenario: Authenticated API request + +- GIVEN a user with a valid OIDC session cookie +- WHEN the browser makes an API request to the frontend +- THEN the frontend extracts the JWT from the server-side OIDC session +- AND forwards it as `Authorization: Bearer ` to the upstream backend + +#### Scenario: Token refresh + +- GIVEN a user's access token has expired but the refresh token is valid +- WHEN the user makes a request +- THEN the frontend refreshes the access token using the refresh token +- AND the OIDC session is updated transparently + +#### Scenario: Logout + +- GIVEN a user clicks logout +- WHEN the logout request is processed +- THEN the frontend destroys the server-side OIDC session +- AND clears the OIDC session cookie +- AND redirects to the SSO logout endpoint for single sign-out + +### Requirement: JWT Validation + +Every backend service that receives a user request SHALL validate the JWT before +processing. Validation SHALL verify: signature against the SSO issuer's JWKS endpoint, +`exp` (expiration), `iss` (issuer), and `aud` (audience). Services MUST reject tokens +that fail any check with HTTP 401. + +#### Scenario: Valid JWT accepted + +- GIVEN a request with a valid, unexpired JWT signed by the SSO issuer +- WHEN the backend receives the request +- THEN the request is processed normally +- AND user identity is extracted from standard OIDC claims (`sub`, `email`, `preferred_username`, `groups`) + +#### Scenario: Expired JWT rejected + +- GIVEN a request with an expired JWT +- WHEN the backend receives the request +- THEN the backend returns 401 Unauthorized + +#### Scenario: Wrong audience rejected + +- GIVEN a JWT with an `aud` claim that does not match the service's expected audience +- WHEN the backend receives the request +- THEN the backend returns 401 Unauthorized + +#### Scenario: Tampered JWT rejected + +- GIVEN a JWT with a modified payload but original signature +- WHEN the backend receives the request +- THEN signature verification fails +- AND the backend returns 401 Unauthorized + +#### Scenario: JWKS key rotation + +- GIVEN the SSO issuer rotates its signing keys +- WHEN a JWT signed with the new key is received +- THEN the backend fetches the updated JWKS +- AND validates the JWT against the new key + +### Requirement: K8s Authorization via Impersonation + +The legacy backend SHALL use its own ServiceAccount token for all Kubernetes API calls +and SHALL set impersonation headers to represent the authenticated user's identity. +K8s RBAC SHALL evaluate permissions as the impersonated user, preserving all existing +per-user RoleBindings and SelfSubjectAccessReview checks. + +The backend ServiceAccount SHALL have a ClusterRole granting the `impersonate` verb on +`users`, `groups`, and `serviceaccounts` resources. The `serviceaccounts` resource is +required because API key tokens represent K8s ServiceAccount identities. + +#### Scenario: List resources respects user RBAC + +- GIVEN a user with access to Project A but not Project B +- WHEN the user lists AgenticSessions +- THEN the backend sets `Impersonate-User` to the user's identity from JWT claims +- AND K8s returns only AgenticSessions in Project A +- AND AgenticSessions in Project B are not visible + +#### Scenario: Create resource with RBAC check + +- GIVEN a user with `create` permission for AgenticSessions in a Project +- WHEN the user creates an AgenticSession +- THEN the backend validates the JWT +- AND sets impersonation headers on the K8s client +- AND the SSAR succeeds because the user has the required RoleBinding +- AND the backend creates the resource using its SA (existing pattern) + +#### Scenario: Unauthorized create rejected + +- GIVEN a user without `create` permission for AgenticSessions in a Project +- WHEN the user attempts to create an AgenticSession +- THEN the backend sets impersonation headers on the K8s client +- AND the SSAR fails +- AND the backend returns 403 Forbidden + +#### Scenario: Audit trail preserved + +- GIVEN a user performs an operation via impersonation +- WHEN K8s audit logging records the API call +- THEN the audit log entry includes the impersonated user identity +- AND the acting ServiceAccount identity + +#### Scenario: Impersonation RBAC enforced + +- GIVEN the backend ServiceAccount +- WHEN the SA attempts to impersonate a user +- THEN K8s verifies the SA has the `impersonate` verb on the appropriate resource +- AND the impersonation succeeds only if the RBAC binding exists + +### Requirement: SSAR Compatibility + +SelfSubjectAccessReview (SSAR) calls SHALL work identically under impersonation. The +backend SHALL issue SSARs via K8s clients configured with impersonation headers so that +K8s evaluates the impersonated user's permissions, not the ServiceAccount's permissions. + +The SSAR result cache SHALL include the impersonated user identity in the cache key. +Under impersonation, the bearer token is the backend ServiceAccount's token (shared +across all requests), so caching by token alone would cause cross-user authorization +leaks. + +#### Scenario: SSAR with impersonation + +- GIVEN a user authenticated via JWT with email `user@example.com` +- WHEN the backend performs an SSAR to check if the user can list AgenticSessions in namespace `project-a` +- THEN the K8s client is configured with `Impersonate-User: user@example.com` +- AND K8s evaluates the SSAR against `user@example.com`'s RoleBindings +- AND the result reflects the user's actual permissions + +#### Scenario: SSAR cache isolation + +- GIVEN user A and user B both make requests +- WHEN the backend caches SSAR results +- THEN user A's cached result is NOT returned for user B +- AND cache keys include the impersonated identity + +### Requirement: API Key Authentication + +API keys (K8s ServiceAccount tokens) SHALL continue to be accepted as an alternative +to SSO JWTs. When the backend receives a bearer token that is not a valid JWT (fails +JWT parsing), it SHALL fall back to Kubernetes TokenReview to validate the token as a +ServiceAccount token. API key identity SHALL be resolved from the ServiceAccount's +annotations (existing pattern). + +This dual-path authentication is required because API keys are minted as K8s +ServiceAccount tokens and cannot be replaced with SSO JWTs. + +#### Scenario: API key accepted + +- GIVEN a request with a valid K8s ServiceAccount token (API key) +- WHEN the backend receives the request +- THEN JWT validation fails (token is not a JWT) +- AND the backend falls back to TokenReview +- AND the token is validated as a K8s ServiceAccount +- AND user identity is resolved from the ServiceAccount's annotations + +#### Scenario: API key impersonation + +- GIVEN a validated API key with a resolved user identity +- WHEN the backend makes K8s API calls +- THEN impersonation headers reflect the API key's associated user +- AND RBAC is enforced for that user + +#### Scenario: Invalid token rejected + +- GIVEN a token that is neither a valid JWT nor a valid K8s ServiceAccount token +- WHEN the backend receives the request +- THEN both JWT validation and TokenReview fail +- AND the backend returns 401 Unauthorized + +### Requirement: Identity Claim Mapping + +User identity SHALL be derived from JWT claims. The following standard OIDC claims +SHALL be used: + +| Claim | Maps to | Used for | +|-------|---------|----------| +| `sub` | User ID | Unique identifier, RoleBinding subjects | +| `email` | User email | Display, notifications, RoleBinding subjects | +| `preferred_username` | Username | Display, audit logs | +| `groups` | Group membership | Group-based RBAC, impersonation groups | + +The platform SHALL support configuring which claim is used for the K8s `Impersonate-User` +value. The default SHALL be `email` to match existing RoleBinding subjects that use +email addresses. + +#### Scenario: Identity extracted from JWT + +- GIVEN a JWT with claims `{"sub": "f:abc:jsell", "email": "jsell@redhat.com", "preferred_username": "jsell", "groups": ["team-ambient"]}` +- WHEN the backend processes the request +- THEN `Impersonate-User` is set to `jsell@redhat.com` +- AND `Impersonate-Group` is set to `["team-ambient"]` + +### Requirement: Runner Token Propagation + +The runner SHALL continue to receive the human user's token as `caller_token` via the +`x-caller-token` header on AG-UI interactions. With SSO authentication, `caller_token` +is a JWT. The runner uses `caller_token` only for API server HTTP calls (credential +fetches, feedback), never for direct K8s API calls. The runner's own K8s access SHALL +continue to use its per-session ServiceAccount bot token. + +#### Scenario: caller_token is a JWT + +- GIVEN a user interacts with a running session via AG-UI +- WHEN the frontend proxies the interaction to the runner +- THEN the `x-caller-token` header contains the user's SSO JWT +- AND the runner uses it for credential fetch calls +- AND the runner falls back to `BOT_TOKEN` if the caller token is expired + +### Requirement: CLI Authentication + +The CLI SHALL authenticate via OIDC Authorization Code Flow with PKCE against the SSO +issuer. The CLI SHALL store the refresh token for automatic token renewal. The CLI +is a public client (it cannot hold a client secret). + +#### Scenario: CLI login + +- GIVEN a user runs the CLI login command +- WHEN the CLI initiates the OIDC flow +- THEN it opens the user's browser to the SSO authorization endpoint with PKCE challenge +- AND listens for the callback on a local port +- AND exchanges the authorization code for tokens +- AND persists the access token and refresh token + +#### Scenario: CLI token refresh + +- GIVEN a user's CLI access token has expired +- WHEN the user runs any CLI command +- THEN the CLI refreshes the token using the stored refresh token +- AND updates the stored tokens + +### Requirement: Local Development Authentication + +The Kind and local-dev environments SHALL include a Keycloak instance as part of the +dev stack, providing a real OIDC flow without requiring VPN access to Red Hat SSO. +This replaces the static JWKS ConfigMap, `DISABLE_AUTH=true` mock mode, and +`OC_TOKEN` / `ENABLE_OC_WHOAMI` env vars as the primary local auth mechanism. + +Keycloak SHALL start with a pre-configured realm requiring no manual setup. +The realm configuration SHALL be version-controlled in the repository as a +Keycloak realm export (JSON). + +The pre-configured realm SHALL include: + +- A confidential client for the frontend BFF (redirect URI to localhost) +- A public client for the CLI (PKCE, redirect to localhost callback) +- A default dev user with admin-level project access and standard OIDC claims + (`email`, `preferred_username`, `groups`) + +The backend and API server SHALL validate JWTs against the local Keycloak's JWKS +endpoint using the same code path as production. No special dev-only validation +logic SHALL exist — the only difference is which JWKS endpoint is configured. + +Mock identity mode (`DISABLE_AUTH=true`) MAY be retained as a lightweight fallback +for rapid iteration when the full OIDC flow is not needed. Mock identity mode +MUST NOT be available in production deployments. + +#### Scenario: Kind cluster bootstrap includes Keycloak + +- GIVEN a developer runs the Kind cluster bootstrap +- WHEN the cluster is ready +- THEN a Keycloak instance is running with the pre-configured realm +- AND the frontend, backend, and API server are configured to use it +- AND no manual Keycloak setup is required + +#### Scenario: Developer login via local Keycloak + +- GIVEN a running Kind cluster with Keycloak +- WHEN a developer navigates to the frontend +- THEN they are redirected to the local Keycloak login page +- AND they can log in with the pre-configured dev credentials +- AND the frontend receives a real JWT and establishes an OIDC session cookie + +#### Scenario: Backend validates local Keycloak JWTs + +- GIVEN a Kind cluster with Keycloak +- WHEN the backend receives a JWT signed by the local Keycloak +- THEN it validates the JWT against Keycloak's JWKS endpoint +- AND extracts identity from standard OIDC claims +- AND impersonation works with the dev user's identity +- AND the validation code path is identical to production + +#### Scenario: CLI authenticates against local Keycloak + +- GIVEN a running Kind cluster with Keycloak +- WHEN a developer runs the CLI login command targeting the local environment +- THEN the CLI performs OIDC auth code + PKCE against the local Keycloak +- AND receives a valid JWT + +#### Scenario: Realm config is version-controlled + +- GIVEN the Keycloak realm export JSON is stored in the repository +- WHEN a developer modifies the realm config (adds a client, changes roles) +- THEN the change is reviewed via normal pull request process +- AND all developers get the updated config on their next cluster bootstrap + +#### Scenario: Mock identity fallback + +- GIVEN `DISABLE_AUTH=true` is set in a local dev environment +- WHEN a request arrives without a JWT +- THEN the backend uses a configurable mock identity +- AND impersonation is set to the mock user +- AND this mode MUST NOT be available in production deployments + +### Requirement: E2E Test Authentication + +End-to-end tests SHALL authenticate without requiring interactive SSO login. The +platform SHALL support a non-interactive authentication path for test automation. +In Kind environments, E2E tests SHALL use the local Keycloak instance. + +#### Scenario: E2E test with client_credentials grant + +- GIVEN an E2E test environment with a Keycloak client_credentials client +- WHEN the test suite starts +- THEN it obtains a JWT via the client_credentials grant against Keycloak +- AND uses the JWT for all API requests during the test run + +#### Scenario: E2E test against local Keycloak + +- GIVEN a Kind cluster with the local Keycloak running +- WHEN the E2E test suite starts +- THEN it authenticates against the local Keycloak using pre-configured test credentials +- AND the backend validates the resulting JWT normally + +#### Scenario: E2E token not exposed to browser + +- GIVEN the E2E test authentication token +- WHEN the test framework injects the token +- THEN the token SHALL be injected server-side (via cookie or API route) +- AND SHALL NOT be exposed as a browser-accessible environment variable + +### Requirement: Feature-Flagged Migration + +The transition from OAuth proxy to SSO authentication SHALL be gated behind a feature +flag (`sso-authentication` in Unleash). During migration, the platform SHALL support +both authentication modes simultaneously. The feature flag SHALL control which +authentication path is active per deployment. + +This is an infrastructure flag, not a user-facing feature toggle. It is not visible +in workspace settings and is not user-configurable. The ops team enables it +per-environment as part of the SSO rollout. + +#### Scenario: Legacy mode (flag off) + +- GIVEN the SSO auth feature flag is disabled +- WHEN a request arrives with an OAuth proxy header +- THEN the backend uses the existing OAuth proxy flow +- AND K8s calls use the opaque token directly as a bearer token + +#### Scenario: SSO mode (flag on) + +- GIVEN the SSO auth feature flag is enabled +- WHEN a request arrives with `Authorization: Bearer ` +- THEN the backend validates the JWT against the JWKS endpoint +- AND K8s calls use impersonation + +#### Scenario: Flag removal + +- GIVEN the SSO auth migration is complete across all environments +- WHEN the feature flag is removed +- THEN all OAuth proxy code paths, forwarded header handling, and opaque token + support SHALL be removed +- AND the OAuth proxy sidecar manifests SHALL be deleted + +### Requirement: Manifest Changes + +The deployment manifests SHALL be updated to support the new authentication model. + +#### Scenario: OAuth proxy sidecar removed + +- GIVEN a production deployment with SSO auth enabled +- WHEN the frontend is deployed +- THEN no OAuth proxy sidecar container is present +- AND the frontend Service routes traffic directly to the Next.js container port + +#### Scenario: SSO client credentials provisioned + +- GIVEN a deployment with SSO auth enabled +- WHEN the frontend pod starts +- THEN a K8s Secret containing `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET`, and `SSO_ISSUER_URL` + is mounted into the frontend container + +### Requirement: SSO Client Configuration + +Each deployed environment SHALL have its own OIDC confidential client registered in +Red Hat SSO. The client SHALL be configured with: + +- Client authentication enabled (confidential) +- Authorization Code grant type +- Valid redirect URI pointing to the frontend OIDC callback route +- Valid post-logout redirect URI pointing to the frontend root +- Web origins matching the frontend host (for CORS on the token endpoint) + +Local development environments (Kind, local-dev) SHALL use a local Keycloak instance +with pre-configured clients instead of registering clients in Red Hat SSO. + +In deployed environments where the platform operates its own Keycloak instance, that +instance MAY be federated to Red Hat SSO via Identity Brokering — Keycloak delegates +login to RH SSO but issues its own tokens. This provides full client management +autonomy without requiring RH SSO realm admin access. + +#### Scenario: One client per environment + +- GIVEN stage and production deployments +- WHEN SSO clients are registered +- THEN each environment has its own client with its own secret +- AND a compromised secret in one environment does not affect others + +#### Scenario: Audience isolation + +- GIVEN separate clients for stage and production +- WHEN a JWT is minted for the stage client +- THEN the `aud` claim contains the stage client ID +- AND the production backend rejects it because the audience does not match + +#### Scenario: Backend impersonation RBAC provisioned + +- GIVEN a deployment with SSO auth enabled +- WHEN the backend pod starts +- THEN the backend ServiceAccount has a ClusterRoleBinding granting `impersonate` verb + on `users`, `groups`, and `serviceaccounts` resources + +## Roadmap + +This spec covers **Phase 1** of a broader IAM consolidation. The full roadmap, informed +by the [IAM consolidation proposal](../../docs/internal/proposals/iam-consolidation-plan.md) +(PR #1466), is: + +| Phase | Scope | Depends on | +|-------|-------|------------| +| **1. SSO user auth + impersonation** (this spec) | Frontend BFF, backend JWT validation, K8s impersonation. API keys and runner auth unchanged. | SSO confidential client registration | +| **2. API keys → SSO service accounts** | Replace K8s SA-based API keys with Keycloak confidential clients. Eliminates TokenReview fallback, K8s SA creation, and `last-used-at` annotation patching. | Keycloak Admin API access (`manage-clients` realm role) | +| **3. Runner auth → OIDC token exchange** | Replace RSA keypair exchange with RFC 8693 token exchange. Runner exchanges projected K8s SA token for an SSO-issued JWT. Eliminates CP token server, RSA bootstrap, and operator 45-min refresh loop. | SSO token exchange enabled; SSO trusts cluster JWKS as identity provider | +| **4. DB RBAC reconciler** | DB `role_bindings` table becomes single write plane. Reconciler syncs K8s RoleBindings from DB state. Eliminates dual-grant problem (K8s RBAC + DB RBAC). | Phases 1-2 complete | +| **5. Credential consolidation** | Move per-user OAuth integration tokens (GitLab, Google, Jira, Gerrit, CodeRabbit) from K8s Secrets to the `credentials` table. Single audit trail and access control. | Phase 4 (DB RBAC) | + +Phase 1 is designed to be independently shippable. Each subsequent phase removes a +category of K8s-managed identity state and moves it to SSO or the database, converging +toward a single IAM plane. + +## Design Decisions + +| Decision | Rationale | +|----------|-----------| +| BFF with confidential client (not public client in browser) | IETF recommendation for web apps. Tokens never reach the browser, eliminating XSS-based token theft. Next.js already acts as a proxy, making BFF natural. | +| K8s impersonation (not cluster OIDC federation) | Platform MUST work on any K8s cluster (Kind, ROSA classic, ROSA HCP) without cluster-level OIDC configuration. Impersonation is a standard K8s feature available everywhere. | +| `email` claim as default impersonation identity | Existing RoleBindings use email addresses as subject names. Using `email` preserves all existing RBAC bindings without migration. | +| Feature-flagged migration (not big-bang cutover) | Enables incremental rollout, environment-by-environment. Legacy OAuth proxy path remains available as fallback. | +| Supersede ADR-0002 (not amend) | ADR-0002's core assumption — the auth token is a K8s-native opaque token — is no longer true. The security contract (user operations use user permissions) is preserved; only the mechanism changes. | +| CLI remains a public client with PKCE | CLIs cannot securely store client secrets. PKCE provides equivalent security for native apps per RFC 7636. | +| Dual-path auth (JWT + TokenReview) | API keys are K8s ServiceAccount tokens that cannot be replaced with SSO JWTs. The backend tries JWT first, falls back to TokenReview, preserving both authentication paths. | +| SSAR cache includes impersonated identity | Under impersonation, the bearer token is shared (backend SA). Caching by token alone would leak authorization decisions across users. | +| E2E tokens injected server-side | Browser-exposed test tokens (via `NEXT_PUBLIC_*` env vars) are an XSS risk. Server-side injection via cookies or API routes prevents accidental token exposure. | +| Local Keycloak for dev (not mock mode or static JWKS) | Real OIDC flow in dev catches integration issues early. Same validation code path as production — no dev-only auth logic to maintain. Replaces ad-hoc static JWKS ConfigMap, `DISABLE_AUTH`, and `OC_TOKEN` env vars. | +| Keycloak Identity Brokering for deployed environments | Federating to RH SSO provides full client management autonomy without requiring realm admin access. Only one client registration needed in RH SSO (the Keycloak instance itself). | + +## References + +- [Security Specification](security.spec.md) — identity boundaries, token propagation +- [K8s Client Usage Patterns](../standards/backend/k8s-client.spec.md) — user-scoped vs. SA client patterns +- [Security Standards](../standards/security/security.spec.md) — token handling, RBAC enforcement +- [ADR-0002](../../docs/internal/adr/0002-user-token-authentication.md) — superseded by this spec +- [OAuth 2.0 for Browser-Based Applications](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps) — BFF recommendation +- [K8s User Impersonation](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation) +- Migration workflow: `workflows/security/sso-migration.workflow.md` +- [IAM consolidation proposal](../../docs/internal/proposals/iam-consolidation-plan.md) (PR #1466) — full IAM audit and long-term consolidation plan diff --git a/workflows/security/sso-migration.workflow.md b/workflows/security/sso-migration.workflow.md new file mode 100644 index 000000000..4fae523be --- /dev/null +++ b/workflows/security/sso-migration.workflow.md @@ -0,0 +1,319 @@ +# SSO Authentication Migration Workflow + +**Spec:** `specs/security/sso-authentication.spec.md` + +## Consumer Migration Map + +Every component that touches user authentication and what changes for each. + +| Consumer | Current behavior | New behavior | Key files | +|----------|-----------------|--------------|-----------| +| Frontend OAuth proxy sidecar | Injects `X-Forwarded-Access-Token`, `X-Forwarded-User`, etc. | Removed; Next.js handles OIDC directly | `manifests/components/oauth-proxy/` | +| Frontend `buildForwardHeadersAsync` | Reads `X-Forwarded-Access-Token` from request, forwards to upstream | Reads JWT from OIDC session, sets `Authorization: Bearer` | `src/lib/auth.ts` | +| Frontend logout | Redirects to `/oauth/sign_out` (OAuth proxy endpoint) | Redirects to Next.js signout → SSO logout | `src/components/navigation.tsx`, `src/app/projects/[name]/layout.tsx` | +| Backend `forwardedIdentityMiddleware` | Reads `X-Forwarded-User/Email/Groups` headers | Reads identity from validated JWT claims | `server/server.go` | +| Backend `GetK8sClientsForRequest` | Uses raw token as `cfg.BearerToken` | Validates JWT, uses SA token + impersonation | `handlers/middleware.go`, `handlers/k8s_clients_for_request_prod.go` | +| Backend SSAR cache | Keyed by `SHA256(token)` | Keyed by `SHA256(token) + impersonated-user` | `handlers/ssar_cache.go` | +| Backend API key auth | TokenReview on SA token | Unchanged — TokenReview is the fallback when JWT parsing fails | `handlers/middleware.go` | +| API server `forwarded_token.go` | Converts `X-Forwarded-Access-Token` to `Authorization` header | Passthrough — JWT arrives in `Authorization` already | `pkg/middleware/forwarded_token.go` | +| Public API `extractToken` | Falls back to `X-Forwarded-Access-Token` | `Authorization: Bearer` only | `handlers/middleware.go` | +| CLI `acpctl login` | OIDC auth code + PKCE against SSO, client ID `ocm-cli` | Same flow, dedicated client ID | `cmd/acpctl/login/cmd.go` | +| SDK (Go, Python) | Accepts token string, sets `Authorization: Bearer` | No change — token format is opaque to SDK | None | +| Runner `caller_token` | Receives opaque token or JWT via `x-caller-token` | Receives JWT via `x-caller-token` | No change — runner treats it as opaque bearer | +| Runner K8s access | Per-session SA bot token | Per-session SA bot token (unchanged) | None | +| E2E tests | Inject SA token via `NEXT_PUBLIC_E2E_TOKEN` (browser-exposed) | Inject test JWT server-side (cookie or API route) | `e2e/cypress/support/commands.ts`, `src/services/api/client.ts` | +| Per-user RoleBindings | `subjects[].name = "user@email.com"` | Same — impersonation uses same email string | None | + +## RBAC Changes + +### Backend ServiceAccount — new impersonation ClusterRole + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: backend-api-impersonator +rules: + - apiGroups: [""] + resources: ["users", "groups", "serviceaccounts"] + verbs: ["impersonate"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: backend-api-impersonator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: backend-api-impersonator +subjects: + - kind: ServiceAccount + name: backend-api + namespace: ambient-code +``` + +## Backend Implementation Notes + +### Dual-path auth flow + +``` +Token received + │ + ├─ Try JWT validation (JWKS) + │ ├─ Success → extract claims → impersonate user + │ └─ Fail (not a JWT) ─┐ + │ │ + └─────────────────────────┤ + │ + Try K8s TokenReview + ├─ Success → resolve SA identity → impersonate + └─ Fail → 401 Unauthorized +``` + +### SSAR cache key change + +Current: `SHA256(token)[:8]:namespace:verb:group:resource` + +With impersonation, `token` is always the backend SA token (same for all requests). +New key must include impersonated identity: + +`SHA256(token)[:8]:impersonated-user:namespace:verb:group:resource` + +### GetK8sClientsForRequest — impersonation config + +The function signature stays the same: `(c *gin.Context) → (kubernetes.Interface, dynamic.Interface)`. +Internally, instead of `cfg.BearerToken = userToken`, use: + +```go +cfg.BearerToken = backendSAToken +cfg.Impersonate = rest.ImpersonationConfig{ + UserName: emailFromJWT, + Groups: groupsFromJWT, +} +``` + +All 142+ callers are unaffected — they receive a K8s client and don't know how it was built. + +### Dual-client pattern preserved + +Some handlers use both user-scoped client (RBAC check) and backend SA client (writes): +- User-scoped: SA token + impersonation (RBAC checked by K8s as impersonated user) +- Backend SA: SA token without impersonation (elevated for writes after RBAC validation) + +The nil-check on `GetK8sClientsForRequest` changes semantics: the SA client never +returns nil (unlike user token clients that return nil on invalid tokens). JWT validation +failures should return 401 before reaching the client construction. + +## Frontend Implementation Notes + +### OIDC session layer + +The frontend needs an OIDC client library that supports: +- Authorization Code Flow with confidential client +- Server-side session storage +- Token refresh +- JWKS validation +- Single sign-out + +`buildForwardHeadersAsync` changes from reading `X-Forwarded-Access-Token` to +extracting the JWT from the OIDC session. The function signature and all 97+ consumers +are unaffected — they call `buildForwardHeadersAsync(request)` and get back headers. + +### Environment variables + +Remove: `OC_TOKEN`, `OC_USER`, `OC_EMAIL`, `ENABLE_OC_WHOAMI` +Add: `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET`, `SSO_ISSUER_URL` +Keep: `DISABLE_AUTH` (mock mode for local dev) + +## Local Keycloak Dev Setup + +### Kind overlay additions + +Add a Keycloak Deployment to the Kind overlay (`overlays/kind/`): + +- Image: `quay.io/keycloak/keycloak` (`start-dev` mode) +- Single replica, H2 in-memory (no persistence needed for dev) +- Realm import via `--import-realm` flag with ConfigMap-mounted JSON +- Service: `keycloak-service` on port 8080 +- NodePort or Ingress for browser access from developer workstation + +### Realm export JSON + +Store at `components/manifests/overlays/kind/keycloak-realm.json`: + +```json +{ + "realm": "ambient", + "enabled": true, + "clients": [ + { + "clientId": "ambient-frontend", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "secret": "dev-secret-do-not-use-in-prod", + "redirectUris": ["http://localhost:3000/api/auth/callback/keycloak"], + "postLogoutRedirectUris": ["http://localhost:3000"], + "webOrigins": ["http://localhost:3000"], + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true + }, + { + "clientId": "ambient-cli", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "redirectUris": ["http://localhost:8400/callback"], + "standardFlowEnabled": true + } + ], + "users": [ + { + "username": "developer", + "email": "developer@local.dev", + "enabled": true, + "credentials": [{"type": "password", "value": "developer", "temporary": false}], + "groups": ["ambient-admin"] + } + ], + "groups": [ + {"name": "ambient-admin"} + ] +} +``` + +### What it replaces in the Kind overlay + +| Current file | Replaced by | +|-------------|-------------| +| `ambient-api-server-jwks-patch.yaml` (static JWKS ConfigMap) | Keycloak's live JWKS endpoint | +| `api-server-no-jwt-patch.yaml` (`--enable-jwt=false`) | `--enable-jwt=true --jwk-cert-url=http://keycloak-service:8080/realms/ambient/protocol/openid-connect/certs` | +| `test-user.yaml` (K8s SA with cluster-admin) | Keycloak dev user + `client_credentials` client for E2E | +| `DISABLE_AUTH=true` in frontend | Frontend configured with `SSO_ISSUER_URL=http://keycloak-service:8080/realms/ambient` | + +### Environment variables for Kind + +``` +# Frontend +SSO_CLIENT_ID=ambient-frontend +SSO_CLIENT_SECRET=dev-secret-do-not-use-in-prod +SSO_ISSUER_URL=http://keycloak-service:8080/realms/ambient + +# Backend / API server +JWKS_URL=http://keycloak-service:8080/realms/ambient/protocol/openid-connect/certs +JWT_AUDIENCE=ambient-frontend + +# CLI (developer workstation, outside cluster) +ISSUER_URL=http://localhost:/realms/ambient +``` + +### Deployed environments with Identity Brokering + +For openshift-dev, mpp, and production, run a Keycloak instance with Identity Brokering: + +1. Add an "OpenID Connect v1.0" Identity Provider in your Keycloak pointing to RH SSO +2. Register your Keycloak as a client in RH SSO (one-time ask to realm admin) +3. Create frontend/CLI clients in your Keycloak (full admin control) +4. Backends validate JWTs against your Keycloak's JWKS — same as Kind, different URL + +This means the same Keycloak realm config (clients, roles) works across all environments. +The only difference is whether Keycloak authenticates users directly (Kind — local +credentials) or delegates to RH SSO (deployed — Identity Brokering). + +## Manifest Changes + +### Remove +- `components/oauth-proxy/` (kustomization, deployment patch, service patch) +- `overlays/production/frontend-oauth-patch.yaml` +- All overlay `kustomization.yaml` references to `oauth-proxy` component + +### Add +- K8s Secret for SSO client credentials (mounted into frontend pod) +- Impersonation ClusterRole + ClusterRoleBinding (above) +- Kind overlay: Keycloak Deployment, Service, ConfigMap (realm JSON), NodePort/Ingress +- Kind overlay: frontend/backend env patches pointing to local Keycloak + +### Update +- Frontend Service: route to port 3000 (Next.js) instead of 8443 (OAuth proxy) +- Frontend Deployment: remove OAuth proxy sidecar container +- Kind overlay: API server patch → `--enable-jwt=true` with Keycloak JWKS URL (replaces `--enable-jwt=false`) +- E2E overlay: use Keycloak `client_credentials` grant instead of K8s SA token + +### Remove (Kind overlay) +- `ambient-api-server-jwks-patch.yaml` (static JWKS ConfigMap — Keycloak serves live JWKS) +- `api-server-no-jwt-patch.yaml` (JWT is now enabled with Keycloak as issuer) +- `test-user.yaml` (K8s SA test user — replaced by Keycloak dev user) + +## Future Phases (from IAM Consolidation Proposal) + +This workflow covers **Phase 1** only. The following phases are defined in +`docs/internal/proposals/iam-consolidation-plan.md` (PR #1466) and should be specced +separately when ready. + +### Phase 2: API keys → SSO service accounts + +Replace `CreateProjectKey()` (which creates K8s SAs + TokenRequest) with Keycloak Admin +API calls to create confidential clients. Users receive `client_id`/`client_secret` +instead of a K8s SA JWT. + +**What goes away:** +- `ambient-key-*` ServiceAccount creation in `handlers/permissions.go` +- `ambient-key-*` RoleBinding creation +- TokenRequest minting for access keys +- `updateAccessKeyLastUsedAnnotation()` (SA annotation patching) +- TokenReview fallback in the auth middleware (all tokens become SSO JWTs) + +**What's new:** +- Keycloak Admin API client in the backend +- `SSO_ADMIN_CLIENT_ID` / `SSO_ADMIN_CLIENT_SECRET` credentials +- Keycloak client roles mapping to `project:admin/edit/view` + +**Prerequisite:** Keycloak Admin API access with `manage-clients` realm role. + +### Phase 3: Runner auth → OIDC token exchange (RFC 8693) + +Replace the RSA keypair exchange between runner and control plane with standard OIDC +token exchange. The runner exchanges its projected K8s SA token for an SSO-issued JWT. + +**What goes away:** +- Operator: SA creation for `ambient-session-*`, TokenRequest minting, 45-min refresh loop +- Operator: Secret `ambient-runner-token-*` creation +- Control plane: entire `internal/tokenserver/` and `internal/keypair/` packages +- Control plane: Secret `ambient-cp-token-keypair` + +**What's new:** +- Runner: OIDC token exchange on startup (exchange K8s SA token → SSO JWT) +- SSO: `ambient-runner-exchange` client with token exchange permission +- SSO: cluster JWKS registered as identity provider (so SSO can validate K8s SA tokens) + +**Prerequisite:** SSO token exchange enabled; SSO trusts cluster JWKS. + +### Phase 4: DB RBAC reconciler + +Make the DB `role_bindings` table the single write plane for permissions. A reconciler +in the control plane watches DB changes and syncs K8s RoleBindings. + +**Role mapping:** `project:owner` → `ambient-project-admin`, `project:editor` → +`ambient-project-edit`, `project:viewer` → `ambient-project-view`. + +Fine-grained permissions (`credential:token-reader`, etc.) remain DB-only — K8s RBAC +enforces the coarse gate (project access), DB RBAC enforces fine-grained actions. + +### Phase 5: Credential consolidation + +Move per-user OAuth integration tokens from K8s Secrets to the `credentials` table. +Add `user_id` and `scope` columns. New routes: `GET/POST/DELETE /users/me/credentials`. + +## ADR-0002 Supersedence + +ADR-0002 chose "User token for all operations" (raw token passthrough) over impersonation +because the token was a K8s-native opaque token — passthrough was the simplest and most +direct approach. With the move to SSO JWTs, the core assumption changes: + +- **ADR-0002 context:** token is K8s-native → passthrough works +- **New context:** token is SSO JWT → passthrough requires cluster OIDC federation + +The security contract from ADR-0002 is preserved: user operations use user permissions, +RBAC is enforced by K8s, audit logs reflect the actual user. Only the mechanism changes +from raw token passthrough to impersonation.