Skip to content

Commit fb5b57c

Browse files
jsell-rhclaudejsell-rh
authored
feat(security): SSO/JWT authentication migration (Phase 1) (#1569)
## Summary SSO authentication migration — replaces OpenShift OAuth proxy with direct OIDC authentication via Keycloak. Includes a validated end-to-end native SSO deployment path (api-server + control plane) that bypasses the legacy backend and operator entirely. ### What's implemented - **Frontend BFF OIDC**: Next.js acts as confidential OIDC client. Browser gets httpOnly session cookie, never sees a raw JWT. Authorization Code Flow with PKCE. Transparent token refresh (5-min access tokens, 30-min sessions). - **Backend JWT validation**: JWKS-based validation via `lestrrat-go/jwx/v2`. Validates signature, expiration, issuer, and audience. - **K8s impersonation**: Backend SA + `Impersonate-User`/`Impersonate-Group` headers preserve all existing RBAC enforcement without cluster OIDC federation. Prefers `preferred_username` over `email` for impersonation (matches Keycloak identity brokering claim order). Defaults to `system:authenticated` group when JWT has no groups. - **Dual-path auth**: JWT validation first, K8s TokenReview fallback for API keys (SA tokens). Both paths use impersonation. - **SSO_ENABLED env var**: SSO can be enabled via env var without depending on Unleash feature flag. - **API server JWK_CERT_URL**: Production environment no longer hardcodes the JWKS URL. Configurable via `JWK_CERT_URL` env var or `--jwk-cert-url` CLI flag, enabling non-RH-SSO OIDC providers (e.g., Keycloak). - **Session expired UX**: Global 401 detection via React Query cache, blocking dialog with login redirect, no retry storms. - **E2E test auth**: `client_credentials` grant from Keycloak with K8s SA fallback. - **Local Keycloak**: Kind cluster includes Keycloak with pre-configured realm, dev users, and protocol mappers. `make kind-sso-toggle` switches between SSO and legacy mode. - **Native SSO deployment workflow**: Complete workflow for deploying with Keycloak SSO, validated on hcmais cluster. See `workflows/deploy-native-sso.md`. - **Migration ordering fix**: roleBindings `typedFKMigration` ID changed from `202505130001` to `202603100139` to sort after the table-creation migration it depends on (`202603100138` creates the `role_bindings` table; the old ID sorted before it, breaking fresh databases). **Production DB impact verified**: the `ambient-code` staging RDS already has `202505130001` in its migrations table. Shipping `202603100139` means gormigrate treats it as a new migration and runs it. Every statement uses `IF EXISTS`/`IF NOT EXISTS`/`DROP NOT NULL` (all idempotent), so all operations no-op on production. The migrations table will contain both IDs — redundant but harmless. Fresh databases (Kind, new namespaces) will only have `202603100139` in the correct sort position. ### Deployment overlays Overlays reorganized by cluster under `components/manifests/overlays/hcmais/`: | Overlay | Purpose | |---------|---------| | `hcmais/jsell-sso-poc/` | Native SSO ambient deployment (api-server + control plane, no oauth-proxy) | | `hcmais/keycloak/` | Keycloak instance deployment | ### Key files | Area | Files | |------|-------| | Spec | `specs/security/sso-authentication.spec.md` | | Deployment workflow | `workflows/deploy-native-sso.md` | | API server JWKS config | `components/ambient-api-server/cmd/ambient-api-server/environments/e_production.go` | | Backend SSO | `components/backend/handlers/sso.go`, `server/server.go` | | Frontend OIDC | `components/frontend/src/lib/oidc.ts`, `session.ts`, `auth.ts` | | Overlay (SSO PoC) | `components/manifests/overlays/hcmais/jsell-sso-poc/` | | Overlay (Keycloak) | `components/manifests/overlays/hcmais/keycloak/` | | RBAC | `base/rbac/backend-clusterrole.yaml`, `base/rbac/control-plane-*` | ### Default behavior - `make kind-up` deploys with **legacy auth** (SA token, no Keycloak redirect) - `make kind-sso-toggle` enables Keycloak OIDC for both frontend and backend - Existing deployments using oauth-proxy are unaffected — SSO code paths only activate with `SSO_ENABLED=true` or the `sso-authentication` Unleash flag - API server falls back to `sso.redhat.com` JWKS if `JWK_CERT_URL` is not set ### Native SSO deployment (validated) The end-to-end path without legacy components was validated on the hcmais cluster: ``` acpctl → Keycloak auth → API Server (REST) → DB ↓ gRPC event Control Plane → Runner Pod → Claude response ↑ gRPC messages Runner → API Server → acpctl ``` Prerequisites for a new environment: 1. Keycloak with 3 clients: `ambient-frontend`, `ambient-control-plane` (service account), `ambient-cli` (public) 2. Identity brokering with RH SSO (optional, for RH user federation) 3. Secrets: `sso-credentials`, `control-plane-oidc`, `ambient-vertex` 4. See `workflows/deploy-native-sso.md` for the complete checklist ## Test plan - [x] Frontend login via Keycloak → session cookie → JWT forwarded to backend - [x] Backend validates JWT, impersonates user, RBAC enforced - [x] API key fallback via TokenReview still works - [x] Token refresh works silently - [x] Session expired dialog appears on refresh token expiry - [x] Logout destroys session + Keycloak single sign-out - [x] `make kind-sso-toggle` switches between SSO and legacy mode - [x] Legacy mode (SA token auth) works when SSO is off - [x] E2E token extraction via Keycloak `client_credentials` - [x] Native SSO: acpctl login via Keycloak → create project → create session → runner pod → Claude response - [x] Native SSO: control plane gRPC watch streams connected to api-server - [x] Native SSO: runner authenticates via CP token server, pushes/watches session messages - [x] Migration ordering: fresh Kind database bootstraps without errors - [x] Migration idempotency: existing databases handle re-run safely 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * SSO via OIDC/Keycloak with session-based login/logout, secure cookies, and backend JWT validation * Dual-mode auth (legacy & SSO) behind a feature flag for gradual rollout * Frontend session-expired dialog, explicit Logged Out page, and SSO-aware logout flow * Improved E2E helpers to run tests in both legacy and SSO modes * **Documentation** * Updated local dev, Kind/Keycloak setup, E2E, and SSO migration/deployment guides <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: jsell-rh <bot@ambient-code.local>
1 parent 351fba7 commit fb5b57c

147 files changed

Lines changed: 5078 additions & 903 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/e2e.yml

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,80 @@ jobs:
242242
echo "Checking services..."
243243
kubectl get svc -n ambient-code
244244
245-
- name: Run Cypress E2E tests
245+
- name: Run Cypress E2E tests (legacy auth)
246246
working-directory: e2e
247247
run: ./scripts/run-tests.sh
248248

249+
- name: Toggle SSO auth mode
250+
run: |
251+
# Verify Keycloak is healthy before toggling (backend needs it for OIDC discovery)
252+
echo "Verifying Keycloak is ready..."
253+
kubectl wait --for=condition=available --timeout=60s deployment/keycloak -n ambient-code
254+
255+
# Enable SSO on frontend
256+
kubectl set env deployment/frontend -n ambient-code SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true
257+
258+
# Enable SSO feature flag in Unleash
259+
UNLEASH_ADMIN_TOKEN=$(kubectl get secret unleash-credentials -n ambient-code -o jsonpath='{.data.admin-api-token}' | base64 -d)
260+
kubectl port-forward -n ambient-code svc/unleash 4242:4242 &
261+
PF=$!
262+
sleep 3
263+
# Create flag if it doesn't exist, then enable
264+
curl -sf -X POST "http://localhost:4242/api/admin/projects/default/features" \
265+
-H "Authorization: $UNLEASH_ADMIN_TOKEN" \
266+
-H "Content-Type: application/json" \
267+
-d '{"name":"sso-authentication","type":"release"}' 2>/dev/null || true
268+
curl -sf -X POST "http://localhost:4242/api/admin/projects/default/features/sso-authentication/environments/development/strategies" \
269+
-H "Authorization: $UNLEASH_ADMIN_TOKEN" \
270+
-H "Content-Type: application/json" \
271+
-d '{"name":"default","parameters":{}}' 2>/dev/null || true
272+
curl -sf -X POST "http://localhost:4242/api/admin/projects/default/features/sso-authentication/environments/development/on" \
273+
-H "Authorization: $UNLEASH_ADMIN_TOKEN" 2>/dev/null || true
274+
kill $PF 2>/dev/null || true
275+
276+
# Wait for frontend rollout
277+
kubectl rollout status deployment/frontend -n ambient-code --timeout=60s
278+
279+
# Restart backend to pick up flag change faster
280+
kubectl rollout restart deployment/backend-api -n ambient-code
281+
kubectl rollout status deployment/backend-api -n ambient-code --timeout=60s
282+
283+
# Verify backend JWT validator initialized (OIDC discovery reached Keycloak)
284+
echo "Verifying backend SSO initialization..."
285+
for i in $(seq 1 15); do
286+
if kubectl logs -n ambient-code -l app=backend-api --tail=50 2>/dev/null | grep -q "SSO: JWT validator initialized"; then
287+
echo "Backend JWT validator initialized"
288+
break
289+
fi
290+
if [ "$i" -eq 15 ]; then
291+
echo "WARNING: Backend JWT validator may not have initialized"
292+
kubectl logs -n ambient-code -l app=backend-api --tail=20 | grep -i sso || true
293+
fi
294+
sleep 2
295+
done
296+
297+
- name: Run Cypress E2E tests (SSO auth)
298+
working-directory: e2e
299+
env:
300+
E2E_USE_SSO: "true"
301+
run: |
302+
# Re-extract token using Keycloak client_credentials
303+
./scripts/extract-token.sh
304+
# Verify frontend is healthy after SSO toggle before running tests
305+
echo "Waiting for frontend to be ready with SSO..."
306+
for i in $(seq 1 30); do
307+
if curl -sf -o /dev/null http://localhost/api/version 2>/dev/null; then
308+
echo "Frontend ready"
309+
break
310+
fi
311+
if [ "$i" -eq 30 ]; then
312+
echo "Frontend not ready after 60s"
313+
kubectl logs -n ambient-code -l app=frontend --tail=20 || true
314+
exit 1
315+
fi
316+
sleep 2
317+
done
318+
./scripts/run-tests.sh
249319
250320
- name: Upload test results
251321
if: failure()

.specify/memory/constitution_update_checklist.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,4 @@ Last sync check: 2025-07-16
8282

8383
---
8484

85-
*This checklist ensures the constitution's principles are consistently applied across all project documentation.*
85+
*This checklist ensures the constitution's principles are consistently applied across all project documentation.*

.specify/scripts/bash/check-prerequisites.sh

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,13 @@ OPTIONS:
5757
EXAMPLES:
5858
# Check task prerequisites (plan.md required)
5959
./check-prerequisites.sh --json
60-
60+
6161
# Check implementation prerequisites (plan.md + tasks.md required)
6262
./check-prerequisites.sh --json --require-tasks --include-tasks
63-
63+
6464
# Get feature paths only (no validation)
6565
./check-prerequisites.sh --paths-only
66-
66+
6767
EOF
6868
exit 0
6969
;;
@@ -147,19 +147,19 @@ if $JSON_MODE; then
147147
json_docs=$(printf '"%s",' "${docs[@]}")
148148
json_docs="[${json_docs%,}]"
149149
fi
150-
150+
151151
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs"
152152
else
153153
# Text output
154154
echo "FEATURE_DIR:$FEATURE_DIR"
155155
echo "AVAILABLE_DOCS:"
156-
156+
157157
# Show status of each potential document
158158
check_file "$RESEARCH" "research.md"
159159
check_file "$DATA_MODEL" "data-model.md"
160160
check_dir "$CONTRACTS_DIR" "contracts/"
161161
check_file "$QUICKSTART" "quickstart.md"
162-
162+
163163
if $INCLUDE_TASKS; then
164164
check_file "$TASKS" "tasks.md"
165165
fi

.specify/scripts/bash/common.sh

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,3 @@ EOF
153153

154154
check_file() { [[ -f "$1" ]] && echo "$2" || echo "$2"; }
155155
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo "$2" || echo "$2"; }
156-

.specify/scripts/bash/create-new-feature.sh

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ i=1
1010
while [ $i -le $# ]; do
1111
arg="${!i}"
1212
case "$arg" in
13-
--json)
14-
JSON_MODE=true
13+
--json)
14+
JSON_MODE=true
1515
;;
1616
--short-name)
1717
if [ $((i + 1)) -gt $# ]; then
@@ -40,7 +40,7 @@ while [ $i -le $# ]; do
4040
fi
4141
BRANCH_NUMBER="$next_arg"
4242
;;
43-
--help|-h)
43+
--help|-h)
4444
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>"
4545
echo ""
4646
echo "Options:"
@@ -54,8 +54,8 @@ while [ $i -le $# ]; do
5454
echo " $0 'Implement OAuth2 integration for API' --number 5"
5555
exit 0
5656
;;
57-
*)
58-
ARGS+=("$arg")
57+
*)
58+
ARGS+=("$arg")
5959
;;
6060
esac
6161
i=$((i + 1))
@@ -83,30 +83,30 @@ find_repo_root() {
8383
# Function to check existing branches (local and remote) and return next available number
8484
check_existing_branches() {
8585
local short_name="$1"
86-
86+
8787
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
8888
git fetch --all --prune 2>/dev/null || true
89-
89+
9090
# Find all branches matching the pattern using git ls-remote (more reliable)
9191
local remote_branches=$(git ls-remote --heads origin 2>/dev/null | grep -E "refs/heads/[0-9]+-${short_name}$" | sed 's/.*\/\([0-9]*\)-.*/\1/' | sort -n)
92-
92+
9393
# Also check local branches
9494
local local_branches=$(git branch 2>/dev/null | grep -E "^[* ]*[0-9]+-${short_name}$" | sed 's/^[* ]*//' | sed 's/-.*//' | sort -n)
95-
95+
9696
# Check specs directory as well
9797
local spec_dirs=""
9898
if [ -d "$SPECS_DIR" ]; then
9999
spec_dirs=$(find "$SPECS_DIR" -maxdepth 1 -type d -name "[0-9]*-${short_name}" 2>/dev/null | xargs -n1 basename 2>/dev/null | sed 's/-.*//' | sort -n)
100100
fi
101-
101+
102102
# Combine all sources and get the highest number
103103
local max_num=0
104104
for num in $remote_branches $local_branches $spec_dirs; do
105105
if [ "$num" -gt "$max_num" ]; then
106106
max_num=$num
107107
fi
108108
done
109-
109+
110110
# Return next number
111111
echo $((max_num + 1))
112112
}
@@ -136,19 +136,19 @@ mkdir -p "$SPECS_DIR"
136136
# Function to generate branch name with stop word filtering and length filtering
137137
generate_branch_name() {
138138
local description="$1"
139-
139+
140140
# Common stop words to filter out
141141
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
142-
142+
143143
# Convert to lowercase and split into words
144144
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
145-
145+
146146
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
147147
local meaningful_words=()
148148
for word in $clean_name; do
149149
# Skip empty words
150150
[ -z "$word" ] && continue
151-
151+
152152
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
153153
if ! echo "$word" | grep -qiE "$stop_words"; then
154154
if [ ${#word} -ge 3 ]; then
@@ -159,12 +159,12 @@ generate_branch_name() {
159159
fi
160160
fi
161161
done
162-
162+
163163
# If we have meaningful words, use first 3-4 of them
164164
if [ ${#meaningful_words[@]} -gt 0 ]; then
165165
local max_words=3
166166
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
167-
167+
168168
local result=""
169169
local count=0
170170
for word in "${meaningful_words[@]}"; do
@@ -220,15 +220,15 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
220220
# Calculate how much we need to trim from suffix
221221
# Account for: feature number (3) + hyphen (1) = 4 chars
222222
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4))
223-
223+
224224
# Truncate suffix at word boundary if possible
225225
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
226226
# Remove trailing hyphen if truncation created one
227227
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
228-
228+
229229
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
230230
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
231-
231+
232232
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
233233
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
234234
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"

.specify/scripts/bash/setup-plan.sh

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ ARGS=()
88

99
for arg in "$@"; do
1010
case "$arg" in
11-
--json)
12-
JSON_MODE=true
11+
--json)
12+
JSON_MODE=true
1313
;;
14-
--help|-h)
14+
--help|-h)
1515
echo "Usage: $0 [--json]"
1616
echo " --json Output results in JSON format"
1717
echo " --help Show this help message"
18-
exit 0
18+
exit 0
1919
;;
20-
*)
21-
ARGS+=("$arg")
20+
*)
21+
ARGS+=("$arg")
2222
;;
2323
esac
2424
done
@@ -53,9 +53,8 @@ if $JSON_MODE; then
5353
"$FEATURE_SPEC" "$IMPL_PLAN" "$FEATURE_DIR" "$CURRENT_BRANCH" "$HAS_GIT"
5454
else
5555
echo "FEATURE_SPEC: $FEATURE_SPEC"
56-
echo "IMPL_PLAN: $IMPL_PLAN"
56+
echo "IMPL_PLAN: $IMPL_PLAN"
5757
echo "SPECS_DIR: $FEATURE_DIR"
5858
echo "BRANCH: $CURRENT_BRANCH"
5959
echo "HAS_GIT: $HAS_GIT"
6060
fi
61-

0 commit comments

Comments
 (0)