From 629466d42e94389cf46aa102dbce63660f04c0be Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 12 May 2026 13:11:37 -0400 Subject: [PATCH 01/39] spec(security): add SSO authentication specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define desired state for migrating from OpenShift OAuth proxy to direct SSO/JWT authentication. Key decisions: - BFF pattern: Next.js as OIDC confidential client, browser gets session cookie - K8s impersonation: backend SA + Impersonate-User/Group preserves RBAC - Dual-path auth: JWT first, TokenReview fallback for API keys - Feature-flagged migration for incremental rollout - Supersedes ADR-0002 (raw token passthrough → impersonation) Includes migration workflow with consumer impact map and implementation notes. Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/security/sso-authentication.spec.md | 414 +++++++++++++++++++ workflows/security/sso-migration.workflow.md | 155 +++++++ 2 files changed, 569 insertions(+) create mode 100644 specs/security/sso-authentication.spec.md create mode 100644 workflows/security/sso-migration.workflow.md diff --git a/specs/security/sso-authentication.spec.md b/specs/security/sso-authentication.spec.md new file mode 100644 index 000000000..a85a45e01 --- /dev/null +++ b/specs/security/sso-authentication.spec.md @@ -0,0 +1,414 @@ +# 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. + +#### 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 callback URL +- 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: 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 platform SHALL support local development on Kind clusters without requiring an +SSO instance. A development mode SHALL allow authentication via: + +1. A static JWT token generated from a local JWKS (for automated testing) +2. A mock identity mode that bypasses JWT validation (for rapid iteration) + +Mock identity mode MUST NOT be available in production deployments. + +#### Scenario: Kind cluster with test JWT + +- GIVEN a Kind cluster with the backend configured with a local JWKS +- WHEN a developer generates a test JWT signed by the local JWKS key +- THEN the backend validates it against the local JWKS +- AND impersonation works with the claims in the test JWT + +#### Scenario: Mock identity mode + +- GIVEN `DISABLE_AUTH=true` is set +- 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. + +#### Scenario: E2E test with client_credentials grant + +- GIVEN an E2E test environment with an SSO service account (client_credentials client) +- WHEN the test suite starts +- THEN it obtains a JWT via the client_credentials grant +- AND uses the JWT for all API requests during the test run + +#### Scenario: E2E test with pre-generated JWT + +- GIVEN a test environment with a local JWKS +- WHEN the test suite starts +- THEN it uses a pre-generated JWT signed by the local JWKS key +- AND the backend validates it 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. During migration, the platform SHALL support both authentication modes +simultaneously. The feature flag SHALL control which authentication path is active +per deployment. + +#### 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 + +#### 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 + +## 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. | + +## 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` diff --git a/workflows/security/sso-migration.workflow.md b/workflows/security/sso-migration.workflow.md new file mode 100644 index 000000000..ee295ad64 --- /dev/null +++ b/workflows/security/sso-migration.workflow.md @@ -0,0 +1,155 @@ +# 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) + +## 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) + +### Update +- Frontend Service: route to port 3000 (Next.js) instead of 8443 (OAuth proxy) +- Frontend Deployment: remove OAuth proxy sidecar container +- E2E overlay: test JWT generation instead of SA token + +## 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. From 86ac6f937add6d5741b9d77504e3636484d75420 Mon Sep 17 00:00:00 2001 From: John Sell Date: Tue, 12 May 2026 15:05:26 -0400 Subject: [PATCH 02/39] spec(security): add IAM consolidation roadmap from PR #1466 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference the IAM consolidation proposal (PR #1466) as the long-term direction. This spec is Phase 1; future phases cover API keys → SSO service accounts, runner → OIDC token exchange, DB RBAC reconciler, and credential consolidation. Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/security/sso-authentication.spec.md | 19 +++++++ workflows/security/sso-migration.workflow.md | 60 ++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/specs/security/sso-authentication.spec.md b/specs/security/sso-authentication.spec.md index a85a45e01..2dfde5361 100644 --- a/specs/security/sso-authentication.spec.md +++ b/specs/security/sso-authentication.spec.md @@ -389,6 +389,24 @@ The deployment manifests SHALL be updated to support the new authentication mode - 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 | @@ -412,3 +430,4 @@ The deployment manifests SHALL be updated to support the new authentication mode - [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 index ee295ad64..490211ff6 100644 --- a/workflows/security/sso-migration.workflow.md +++ b/workflows/security/sso-migration.workflow.md @@ -141,6 +141,66 @@ Keep: `DISABLE_AUTH` (mock mode for local dev) - Frontend Deployment: remove OAuth proxy sidecar container - E2E overlay: test JWT generation instead of SA token +## 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 From 2f0f7753a54fd926e07187dce0463ad4b8d9ebd9 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 13 May 2026 12:21:42 -0400 Subject: [PATCH 03/39] spec(security): add OIDC callback route coexistence and client config requirements - OIDC callback must coexist with existing integration auth routes - SSO client configuration requirements (one per environment, audience isolation) - Post-logout redirect URI and web origins specified Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/security/sso-authentication.spec.md | 40 ++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/specs/security/sso-authentication.spec.md b/specs/security/sso-authentication.spec.md index 2dfde5361..9f009f313 100644 --- a/specs/security/sso-authentication.spec.md +++ b/specs/security/sso-authentication.spec.md @@ -36,6 +36,10 @@ The browser SHALL receive an opaque, httpOnly, secure, SameSite OIDC session coo 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 @@ -46,11 +50,18 @@ proxying requests to backend services. #### Scenario: OIDC callback - GIVEN the user completes SSO authentication -- WHEN SSO redirects to the frontend callback URL +- 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 @@ -382,6 +393,33 @@ The deployment manifests SHALL be updated to support the new authentication mode - 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 NOT require an SSO client. + +#### 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 From a8e7080e311f2e97103da6d2f0be8334b9cf8b6c Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 11:03:58 -0400 Subject: [PATCH 04/39] spec(security): add local Keycloak dev instance requirement - Kind/local-dev environments include Keycloak with pre-configured realm - Replaces static JWKS ConfigMap, DISABLE_AUTH mock mode, and OC_TOKEN - Same JWT validation code path as production (no dev-only auth logic) - Realm config version-controlled as JSON export - E2E tests use local Keycloak in Kind environments - Design decision: Keycloak Identity Brokering for deployed environments Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/security/sso-authentication.spec.md | 97 ++++++++++++++++++----- 1 file changed, 77 insertions(+), 20 deletions(-) diff --git a/specs/security/sso-authentication.spec.md b/specs/security/sso-authentication.spec.md index 9f009f313..62cb666c6 100644 --- a/specs/security/sso-authentication.spec.md +++ b/specs/security/sso-authentication.spec.md @@ -297,24 +297,72 @@ is a public client (it cannot hold a client secret). ### Requirement: Local Development Authentication -The platform SHALL support local development on Kind clusters without requiring an -SSO instance. A development mode SHALL allow authentication via: +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. -1. A static JWT token generated from a local JWKS (for automated testing) -2. A mock identity mode that bypasses JWT validation (for rapid iteration) +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). -Mock identity mode MUST NOT be available in production deployments. +The pre-configured realm SHALL include: -#### Scenario: Kind cluster with test JWT +- 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`) -- GIVEN a Kind cluster with the backend configured with a local JWKS -- WHEN a developer generates a test JWT signed by the local JWKS key -- THEN the backend validates it against the local JWKS -- AND impersonation works with the claims in the test JWT +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. -#### Scenario: Mock identity mode +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. -- GIVEN `DISABLE_AUTH=true` is set +#### 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 @@ -324,20 +372,21 @@ Mock identity mode MUST NOT be available in production deployments. 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 an SSO service account (client_credentials client) +- 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 +- 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 with pre-generated JWT +#### Scenario: E2E test against local Keycloak -- GIVEN a test environment with a local JWKS -- WHEN the test suite starts -- THEN it uses a pre-generated JWT signed by the local JWKS key -- AND the backend validates it normally +- 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 @@ -404,7 +453,13 @@ Red Hat SSO. The client SHALL be configured with: - 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 NOT require an SSO client. +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 @@ -458,6 +513,8 @@ toward a single IAM plane. | 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 From 64ad5a3f77f0f617a2b36b851eb1966cedc5d702 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 11:13:29 -0400 Subject: [PATCH 05/39] workflow(security): add local Keycloak dev setup and Identity Brokering - Keycloak Deployment, realm JSON, and env var config for Kind overlay - Maps what it replaces (static JWKS, DISABLE_AUTH, test-user SA) - Identity Brokering section for deployed environments - Updated manifest changes to include Kind overlay additions/removals Co-Authored-By: Claude Opus 4.6 (1M context) --- workflows/security/sso-migration.workflow.md | 106 ++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/workflows/security/sso-migration.workflow.md b/workflows/security/sso-migration.workflow.md index 490211ff6..4fae523be 100644 --- a/workflows/security/sso-migration.workflow.md +++ b/workflows/security/sso-migration.workflow.md @@ -125,6 +125,102 @@ 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 @@ -135,11 +231,19 @@ Keep: `DISABLE_AUTH` (mock mode for local dev) ### 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 -- E2E overlay: test JWT generation instead of SA token +- 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) From 296ea91c7f408825f272fd96591fac44bb3803a0 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 12:27:25 -0400 Subject: [PATCH 06/39] feat(security): add Keycloak dev instance, JWT validation package, and impersonation RBAC Slice 1 of SSO authentication migration (Phase 1): - Deploy Keycloak to Kind cluster with pre-configured realm (ambient-code) including confidential frontend client, public CLI client, and E2E client_credentials client. Dev users: developer/developer, admin/admin. - Add jwtauth package with JWKS-based JWT validation using lestrrat-go/jwx/v2. Validates signature, expiration, issuer, and audience. Extracts OIDC claims (sub, email, preferred_username, groups). - Add impersonate verb on users, groups, and serviceaccounts to backend-api ClusterRole for K8s impersonation under SSO auth. - Fix Kind overlay: relax runAsNonRoot for ambient-api-server, make control-plane OIDC env vars optional. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/go.mod | 8 + components/backend/go.sum | 17 + components/backend/jwtauth/validator.go | 165 +++++++++ components/backend/jwtauth/validator_test.go | 315 ++++++++++++++++++ .../base/rbac/backend-clusterrole.yaml | 5 + .../kind/api-server-security-patch.yaml | 9 + .../kind/control-plane-env-patch.yaml | 26 ++ .../overlays/kind/keycloak-deployment.yaml | 85 +++++ .../overlays/kind/keycloak-realm.json | 187 +++++++++++ .../overlays/kind/kustomization.yaml | 24 ++ .../overlays/kind/sso-credentials.yaml | 13 + 11 files changed, 854 insertions(+) create mode 100644 components/backend/jwtauth/validator.go create mode 100644 components/backend/jwtauth/validator_test.go create mode 100644 components/manifests/overlays/kind/api-server-security-patch.yaml create mode 100644 components/manifests/overlays/kind/control-plane-env-patch.yaml create mode 100644 components/manifests/overlays/kind/keycloak-deployment.yaml create mode 100644 components/manifests/overlays/kind/keycloak-realm.json create mode 100644 components/manifests/overlays/kind/sso-credentials.yaml 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/jwtauth/validator.go b/components/backend/jwtauth/validator.go new file mode 100644 index 000000000..553139329 --- /dev/null +++ b/components/backend/jwtauth/validator.go @@ -0,0 +1,165 @@ +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 + audience string +} + +func NewValidator(issuerURL, audience string) (*Validator, error) { + if issuerURL == "" { + return nil, fmt.Errorf("issuer URL is required") + } + + jwksURL, err := discoverJWKSURL(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) + } + + // Pre-fetch keys to fail fast on misconfiguration + 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: issuerURL, + 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 +} + +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) + } + + opts := []jwt.ParseOption{ + jwt.WithKeySet(keySet), + jwt.WithValidate(true), + jwt.WithIssuer(v.issuer), + } + 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) + } + + 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 discoverJWKSURL(issuerURL string) (string, error) { + wellKnownURL := issuerURL + "/.well-known/openid-configuration" + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.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 { + 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") + } + + return 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..c94bd05ac --- /dev/null +++ b/components/backend/jwtauth/validator_test.go @@ -0,0 +1,315 @@ +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 + + jwksURL, err := discoverJWKSURL(server.URL) + if err != nil { + t.Fatalf("discoverJWKSURL: %v", err) + } + expected := server.URL + "/jwks" + if jwksURL != expected { + t.Errorf("jwksURL = %q, want %q", jwksURL, expected) + } +} 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/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/keycloak-deployment.yaml b/components/manifests/overlays/kind/keycloak-deployment.yaml new file mode 100644 index 000000000..3428dbdd7 --- /dev/null +++ b/components/manifests/overlays/kind/keycloak-deployment.yaml @@ -0,0 +1,85 @@ +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_STRICT + value: "false" + - name: KC_HTTP_ENABLED + value: "true" + - name: KC_PROXY_HEADERS + value: "xforwarded" + - 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..ab7f19369 --- /dev/null +++ b/components/manifests/overlays/kind/keycloak-realm.json @@ -0,0 +1,187 @@ +{ + "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": [ + "http://localhost:*", + "http://frontend-service:3000" + ], + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "fullScopeAllowed": true, + "defaultClientScopes": [ + "openid", + "email", + "profile", + "groups" + ], + "protocolMappers": [ + { + "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, + "defaultClientScopes": [ + "openid", + "email", + "profile", + "groups" + ] + } + ], + "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..6de653675 100644 --- a/components/manifests/overlays/kind/kustomization.yaml +++ b/components/manifests/overlays/kind/kustomization.yaml @@ -18,6 +18,9 @@ resources: - postgresql-init-scripts.yaml - ldap-config.yaml - ldap-credentials.yaml +# SSO: Keycloak for local OIDC authentication +- keycloak-deployment.yaml +- sso-credentials.yaml # Patches for e2e environment patches: @@ -114,6 +117,27 @@ 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 +# 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..1a1c49cba --- /dev/null +++ b/components/manifests/overlays/kind/sso-credentials.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Secret +metadata: + name: sso-credentials + labels: + app: ambient-sso +type: Opaque +stringData: + SSO_ISSUER_URL: "http://keycloak-service.ambient-code.svc.cluster.local:8080/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" From a5e3647c4238ff0ad1f9f2896a876c052db69423 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 13:17:37 -0400 Subject: [PATCH 07/39] feat(security): add backend dual-path JWT/TokenReview auth with K8s impersonation Slice 2 of SSO authentication migration (Phase 1): - Wire JWT validation into backend middleware: forwardedIdentityMiddleware validates JWT against Keycloak JWKS, extracts identity from OIDC claims (sub, email, preferred_username, groups), and stores validated claims in Gin context for reuse by handlers. - Add dual-path auth in getK8sClientsDefault: JWT validation first, then TokenReview fallback for API keys (K8s ServiceAccount tokens). - Use K8s impersonation (Impersonate-User/Group) instead of raw bearer token when SSO is enabled. Backend SA token + impersonation preserves all existing RBAC enforcement. - Fix SSAR cache key to include impersonated identity instead of shared SA token, preventing cross-user authorization cache leaks. - Gate SSO path behind "sso-authentication" Unleash feature flag. - Add SSO env vars (SSO_ISSUER_URL, SSO_AUDIENCE) to backend Kind overlay. - Fix Keycloak realm: add audience mapper and protocol mappers for sub, email, preferred_username claims in access token. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/handlers/middleware.go | 69 ++++++---- components/backend/handlers/ssar_cache.go | 8 +- components/backend/handlers/sso.go | 125 ++++++++++++++++++ components/backend/main.go | 1 + components/backend/server/k8s.go | 25 ++++ components/backend/server/server.go | 68 ++++++++-- components/backend/server/server_test.go | 14 +- .../overlays/kind/backend-sso-patch.yaml | 22 +++ .../overlays/kind/keycloak-realm.json | 12 ++ .../overlays/kind/kustomization.yaml | 7 + .../overlays/kind/sso-credentials.yaml | 2 +- 11 files changed, 308 insertions(+), 45 deletions(-) create mode 100644 components/backend/handlers/sso.go create mode 100644 components/manifests/overlays/kind/backend-sso-patch.yaml 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/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..945c03fc3 --- /dev/null +++ b/components/backend/handlers/sso.go @@ -0,0 +1,125 @@ +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/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..0350147bc 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,27 @@ 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 + } + + 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/manifests/overlays/kind/backend-sso-patch.yaml b/components/manifests/overlays/kind/backend-sso-patch.yaml new file mode 100644 index 000000000..f1e33ec35 --- /dev/null +++ b/components/manifests/overlays/kind/backend-sso-patch.yaml @@ -0,0 +1,22 @@ +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 diff --git a/components/manifests/overlays/kind/keycloak-realm.json b/components/manifests/overlays/kind/keycloak-realm.json index ab7f19369..e850d309f 100644 --- a/components/manifests/overlays/kind/keycloak-realm.json +++ b/components/manifests/overlays/kind/keycloak-realm.json @@ -35,6 +35,18 @@ "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", diff --git a/components/manifests/overlays/kind/kustomization.yaml b/components/manifests/overlays/kind/kustomization.yaml index 6de653675..2f39f7b01 100644 --- a/components/manifests/overlays/kind/kustomization.yaml +++ b/components/manifests/overlays/kind/kustomization.yaml @@ -124,6 +124,13 @@ patches: 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: diff --git a/components/manifests/overlays/kind/sso-credentials.yaml b/components/manifests/overlays/kind/sso-credentials.yaml index 1a1c49cba..11c686bcd 100644 --- a/components/manifests/overlays/kind/sso-credentials.yaml +++ b/components/manifests/overlays/kind/sso-credentials.yaml @@ -6,7 +6,7 @@ metadata: app: ambient-sso type: Opaque stringData: - SSO_ISSUER_URL: "http://keycloak-service.ambient-code.svc.cluster.local:8080/realms/ambient-code" + 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_AUDIENCE: "ambient-frontend" From c7ed384323a970e397a7f01203d300c71fc00071 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 14:05:21 -0400 Subject: [PATCH 08/39] feat(frontend): add BFF OIDC authentication with Keycloak Slice 3 of SSO authentication migration (Phase 1): - Add openid-client v6, iron-session v8, and jose as dependencies - Create OIDC client layer (src/lib/oidc.ts): discovery, authorization URL construction with PKCE, code exchange, token refresh, end-session URL - Create encrypted session cookie management (src/lib/session.ts): iron-session with httpOnly/secure/sameSite cookies, transparent token refresh when access token is within 60s of expiry - Add SSO API routes: - /api/auth/sso/login: generates PKCE, stores verifier/state in cookies, redirects to Keycloak authorization endpoint - /api/auth/sso/callback: exchanges code for tokens, stores in session - /api/auth/sso/logout: destroys session, redirects to Keycloak logout - Add Next.js middleware: redirects unauthenticated page requests to SSO login when SSO_ENABLED=true - Modify buildForwardHeadersAsync: SSO path extracts JWT from session, sets Authorization: Bearer and X-Forwarded-* headers from JWT claims. All 97+ consumers are unaffected. - Update navigation logout to use SSO logout route when enabled - Update /api/me to accept Authorization header for auth check - Add SSO env vars to Kind frontend deployment patch - Support SSO_PUBLIC_ISSUER_URL for Kind dev (browser vs cluster URLs) Co-Authored-By: Claude Opus 4.6 (1M context) --- components/frontend/package-lock.json | 73 +++++++++++ components/frontend/package.json | 3 + .../src/app/api/auth/sso/callback/route.ts | 40 ++++++ .../src/app/api/auth/sso/login/route.ts | 36 ++++++ .../src/app/api/auth/sso/logout/route.ts | 18 +++ components/frontend/src/app/api/me/route.ts | 2 +- .../frontend/src/components/navigation.tsx | 8 +- components/frontend/src/lib/auth.ts | 40 ++++++ components/frontend/src/lib/env.ts | 12 ++ components/frontend/src/lib/oidc.ts | 117 ++++++++++++++++++ components/frontend/src/lib/session.ts | 52 ++++++++ components/frontend/src/middleware.ts | 22 ++++ .../overlays/kind/frontend-test-patch.yaml | 33 +++++ 13 files changed, 452 insertions(+), 4 deletions(-) create mode 100644 components/frontend/src/app/api/auth/sso/callback/route.ts create mode 100644 components/frontend/src/app/api/auth/sso/login/route.ts create mode 100644 components/frontend/src/app/api/auth/sso/logout/route.ts create mode 100644 components/frontend/src/lib/oidc.ts create mode 100644 components/frontend/src/lib/session.ts create mode 100644 components/frontend/src/middleware.ts diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json index ce04bdbd3..ddbedceaf 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.3", "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 c20ccdccd..f9b7ca468 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.3", "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..a2b0deec0 --- /dev/null +++ b/components/frontend/src/app/api/auth/sso/callback/route.ts @@ -0,0 +1,40 @@ +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) { + return NextResponse.json( + { error: "Missing OIDC state — please try logging in again" }, + { status: 400 }, + ); + } + + try { + const tokens = await exchangeCode(request.nextUrl, 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"); + + return NextResponse.redirect(new URL(returnTo, request.nextUrl.origin)); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { error: "OIDC callback failed", detail: message }, + { status: 500 }, + ); + } +} 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..5b9c4ad3e --- /dev/null +++ b/components/frontend/src/app/api/auth/sso/login/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +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 cookieStore = await cookies(); + cookieStore.set("oidc_code_verifier", codeVerifier, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 600, + }); + cookieStore.set("oidc_state", state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 600, + }); + cookieStore.set("oidc_return_to", returnTo, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 600, + }); + + return NextResponse.redirect(url); +} 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..89ede9a15 --- /dev/null +++ b/components/frontend/src/app/api/auth/sso/logout/route.ts @@ -0,0 +1,18 @@ +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 = 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..7418082a5 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 }); diff --git a/components/frontend/src/components/navigation.tsx b/components/frontend/src/components/navigation.tsx index 7902c1aa4..281745fa7 100644 --- a/components/frontend/src/components/navigation.tsx +++ b/components/frontend/src/components/navigation.tsx @@ -26,9 +26,11 @@ export function Navigation({ feedbackUrl }: NavigationProps) { 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 (process.env.NEXT_PUBLIC_SSO_ENABLED === 'true') { + window.location.href = '/api/auth/sso/logout'; + } else { + window.location.href = '/oauth/sign_out'; + } }; const handleMobileNav = (path: string) => { diff --git a/components/frontend/src/lib/auth.ts b/components/frontend/src/lib/auth.ts index 59d13891b..0e553d1d3 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,38 @@ 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', + }; + + const accessToken = await getAccessToken(); + 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..bc40b842f --- /dev/null +++ b/components/frontend/src/lib/oidc.ts @@ -0,0 +1,117 @@ +import * as client from "openid-client"; + +let cachedConfig: client.Configuration | null = null; + +async function getOIDCConfig(): Promise { + if (cachedConfig) 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:"; + + cachedConfig = await client.discovery( + serverUrl, + clientId, + clientSecret, + undefined, + useInsecure ? { execute: [client.allowInsecureRequests] } : undefined, + ); + 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, + }); + + // In Kind/dev, the browser needs to reach Keycloak via an external URL + // (e.g., localhost:30090) while the server uses the internal cluster URL. + const publicIssuer = process.env.SSO_PUBLIC_ISSUER_URL; + if (publicIssuer) { + const internalIssuer = process.env.SSO_ISSUER_URL || ""; + const url = redirectTo.href.replace(internalIssuer, publicIssuer); + return { url, codeVerifier, 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; + } + let endSessionUrl = String(endSessionEndpoint); + const publicIssuer = process.env.SSO_PUBLIC_ISSUER_URL; + const internalIssuer = process.env.SSO_ISSUER_URL || ""; + if (publicIssuer && internalIssuer) { + endSessionUrl = endSessionUrl.replace(internalIssuer, publicIssuer); + } + const url = new URL(endSessionUrl); + 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/session.ts b/components/frontend/src/lib/session.ts new file mode 100644 index 000000000..4e023fc5b --- /dev/null +++ b/components/frontend/src/lib/session.ts @@ -0,0 +1,52 @@ +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 { + 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(); + return session.accessToken; + } catch { + session.destroy(); + return undefined; + } +} diff --git a/components/frontend/src/middleware.ts b/components/frontend/src/middleware.ts new file mode 100644 index 000000000..d8496de0f --- /dev/null +++ b/components/frontend/src/middleware.ts @@ -0,0 +1,22 @@ +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(); + } + + 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|_next|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map)).*)", + ], +}; diff --git a/components/manifests/overlays/kind/frontend-test-patch.yaml b/components/manifests/overlays/kind/frontend-test-patch.yaml index a8fc76276..394e18176 100644 --- a/components/manifests/overlays/kind/frontend-test-patch.yaml +++ b/components/manifests/overlays/kind/frontend-test-patch.yaml @@ -31,3 +31,36 @@ spec: value: "system:serviceaccount:ambient-code:test-user" - name: OC_EMAIL value: "test-user@vteam.local" + # SSO/OIDC configuration + - name: SSO_ENABLED + value: "true" + - name: NEXT_PUBLIC_SSO_ENABLED + value: "true" + - 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:11646/api/auth/sso/callback" + - name: SSO_PUBLIC_ISSUER_URL + value: "http://localhost:30090/realms/ambient-code" From 64613fdc472672aff1b562e4c42b40b70bc1af34 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 14:11:04 -0400 Subject: [PATCH 09/39] fix(security): use portless localhost redirect URI for Keycloak Keycloak supports any port for localhost redirect URIs per RFC 8252 section 7.3. Registering http://localhost/* (without port) accepts callbacks on any ephemeral port, eliminating port-forward mismatches. Also set webOrigins to "+" (all valid redirect origins) for CORS. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/manifests/overlays/kind/keycloak-realm.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/components/manifests/overlays/kind/keycloak-realm.json b/components/manifests/overlays/kind/keycloak-realm.json index e850d309f..b2ab337ea 100644 --- a/components/manifests/overlays/kind/keycloak-realm.json +++ b/components/manifests/overlays/kind/keycloak-realm.json @@ -13,16 +13,15 @@ "publicClient": false, "secret": "dev-secret-do-not-use-in-prod", "redirectUris": [ - "http://localhost:*/*", + "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/*" + "post.logout.redirect.uris": "http://localhost/*##http://frontend-service:3000/*" }, "webOrigins": [ - "http://localhost:*", - "http://frontend-service:3000" + "+" ], "standardFlowEnabled": true, "directAccessGrantsEnabled": true, From 63148f0cc52a939dd6ce227f2f8f70a204187205 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 14:14:43 -0400 Subject: [PATCH 10/39] fix(frontend): use standard URL for OIDC callback and match redirect_uri openid-client v6 requires a standard URL instance (not NextURL). Construct callback URL from SSO_REDIRECT_URI base to match the redirect_uri sent during authorization, since request.url inside the container resolves to 0.0.0.0:3000 rather than localhost:11646. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../frontend/src/app/api/auth/sso/callback/route.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/frontend/src/app/api/auth/sso/callback/route.ts b/components/frontend/src/app/api/auth/sso/callback/route.ts index a2b0deec0..201f501e9 100644 --- a/components/frontend/src/app/api/auth/sso/callback/route.ts +++ b/components/frontend/src/app/api/auth/sso/callback/route.ts @@ -17,7 +17,13 @@ export async function GET(request: NextRequest) { } try { - const tokens = await exchangeCode(request.nextUrl, codeVerifier, expectedState); + 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; From a71847779c2513130a02a808e819303e5e2d304a Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 14:17:50 -0400 Subject: [PATCH 11/39] fix(frontend): remap iss parameter in OIDC callback for dual-URL envs In Kind, Keycloak's iss response parameter uses the public URL (localhost:30090) while openid-client validates against the internal URL (keycloak-service:8080). Remap the iss param before passing to authorizationCodeGrant so RFC 9207 issuer validation passes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../frontend/src/app/api/auth/sso/callback/route.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/components/frontend/src/app/api/auth/sso/callback/route.ts b/components/frontend/src/app/api/auth/sso/callback/route.ts index 201f501e9..b2ea2233f 100644 --- a/components/frontend/src/app/api/auth/sso/callback/route.ts +++ b/components/frontend/src/app/api/auth/sso/callback/route.ts @@ -23,6 +23,14 @@ export async function GET(request: NextRequest) { incomingUrl.searchParams.forEach((value, key) => { callbackUrl.searchParams.set(key, value); }); + + // Remap the iss parameter from the public URL to the internal URL + // so openid-client's RFC 9207 issuer validation passes. + const publicIssuer = process.env.SSO_PUBLIC_ISSUER_URL; + const internalIssuer = process.env.SSO_ISSUER_URL; + if (publicIssuer && internalIssuer && callbackUrl.searchParams.get("iss") === publicIssuer) { + callbackUrl.searchParams.set("iss", internalIssuer); + } const tokens = await exchangeCode(callbackUrl, codeVerifier, expectedState); const session = await getSession(); session.accessToken = tokens.accessToken; From c7786b9e3f781811a771119377ee2a25646c008b Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 14:23:27 -0400 Subject: [PATCH 12/39] fix(security): set KC_HOSTNAME for consistent issuer across URLs Set KC_HOSTNAME to the internal service URL so Keycloak uses a consistent issuer in all tokens and OIDC responses, regardless of whether the browser reaches it via localhost:30090 or the server reaches it via keycloak-service:8080. This eliminates issuer mismatches in ID token validation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../frontend/src/app/api/auth/sso/callback/route.ts | 8 ++++---- .../manifests/overlays/kind/keycloak-deployment.yaml | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/components/frontend/src/app/api/auth/sso/callback/route.ts b/components/frontend/src/app/api/auth/sso/callback/route.ts index b2ea2233f..4aa8bc7b5 100644 --- a/components/frontend/src/app/api/auth/sso/callback/route.ts +++ b/components/frontend/src/app/api/auth/sso/callback/route.ts @@ -24,11 +24,11 @@ export async function GET(request: NextRequest) { callbackUrl.searchParams.set(key, value); }); - // Remap the iss parameter from the public URL to the internal URL - // so openid-client's RFC 9207 issuer validation passes. - const publicIssuer = process.env.SSO_PUBLIC_ISSUER_URL; + // Keycloak sends iss matching KC_HOSTNAME (internal URL). If it somehow + // doesn't match (e.g., KC_HOSTNAME not set), remap to the configured issuer. const internalIssuer = process.env.SSO_ISSUER_URL; - if (publicIssuer && internalIssuer && callbackUrl.searchParams.get("iss") === publicIssuer) { + const callbackIss = callbackUrl.searchParams.get("iss"); + if (internalIssuer && callbackIss && callbackIss !== internalIssuer) { callbackUrl.searchParams.set("iss", internalIssuer); } const tokens = await exchangeCode(callbackUrl, codeVerifier, expectedState); diff --git a/components/manifests/overlays/kind/keycloak-deployment.yaml b/components/manifests/overlays/kind/keycloak-deployment.yaml index 3428dbdd7..1d6c7baa1 100644 --- a/components/manifests/overlays/kind/keycloak-deployment.yaml +++ b/components/manifests/overlays/kind/keycloak-deployment.yaml @@ -23,12 +23,10 @@ spec: - start-dev - --import-realm env: - - name: KC_HOSTNAME_STRICT - value: "false" + - name: KC_HOSTNAME + value: "http://keycloak-service:8080" - name: KC_HTTP_ENABLED value: "true" - - name: KC_PROXY_HEADERS - value: "xforwarded" - name: KEYCLOAK_ADMIN value: admin - name: KEYCLOAK_ADMIN_PASSWORD From 2bdb6eae9531a1e8a05ce7d3c724526c80c6a497 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 15:04:47 -0400 Subject: [PATCH 13/39] fix(security): handle split-URL Keycloak in Kind dev environments In Kind, the browser reaches Keycloak via localhost:30090 while backend and frontend servers use keycloak-service:8080. Keycloak sets the token issuer based on the authorization session URL, causing mismatches. Fixes: - Add alt issuer support to JWT validator (AddAltIssuer) so the backend accepts tokens from both internal and public Keycloak URLs. Production environments use a single URL and don't need alt issuers. - Use standard openid-client authorizationCodeGrant in production (full ID token validation). Fall back to manual token exchange in dev when SSO_PUBLIC_ISSUER_URL differs from SSO_ISSUER_URL. - Set cookies directly on redirect response in login route (cookies() API mutations don't transfer to NextResponse.redirect). - Derive post-login redirect origin from SSO_REDIRECT_URI to avoid container-internal 0.0.0.0:3000 address. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/jwtauth/validator.go | 37 +++++++++-- components/backend/server/k8s.go | 5 ++ .../src/app/api/auth/sso/callback/route.ts | 5 +- .../src/app/api/auth/sso/login/route.ts | 28 +++----- components/frontend/src/lib/oidc.ts | 65 ++++++++++++++++++- .../overlays/kind/backend-sso-patch.yaml | 2 + .../overlays/kind/keycloak-deployment.yaml | 4 +- 7 files changed, 115 insertions(+), 31 deletions(-) diff --git a/components/backend/jwtauth/validator.go b/components/backend/jwtauth/validator.go index 553139329..412698554 100644 --- a/components/backend/jwtauth/validator.go +++ b/components/backend/jwtauth/validator.go @@ -22,10 +22,11 @@ type Claims struct { } type Validator struct { - jwksCache *jwk.Cache - jwksURL string - issuer string - audience string + jwksCache *jwk.Cache + jwksURL string + issuer string + altIssuers []string + audience string } func NewValidator(issuerURL, audience string) (*Validator, error) { @@ -80,16 +81,26 @@ func NewValidatorWithJWKSURL(jwksURL, issuer, audience string) (*Validator, erro }, 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), - jwt.WithIssuer(v.issuer), } if v.audience != "" { opts = append(opts, jwt.WithAudience(v.audience)) @@ -100,6 +111,10 @@ func (v *Validator) Validate(tokenString string) (*Claims, error) { 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(), @@ -136,6 +151,18 @@ func (v *Validator) Validate(tokenString string) (*Claims, error) { 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 discoverJWKSURL(issuerURL string) (string, error) { wellKnownURL := issuerURL + "/.well-known/openid-configuration" diff --git a/components/backend/server/k8s.go b/components/backend/server/k8s.go index 0350147bc..6f6949969 100644 --- a/components/backend/server/k8s.go +++ b/components/backend/server/k8s.go @@ -83,6 +83,11 @@ func InitJWTValidator() { 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) } diff --git a/components/frontend/src/app/api/auth/sso/callback/route.ts b/components/frontend/src/app/api/auth/sso/callback/route.ts index 4aa8bc7b5..4e79f8a3f 100644 --- a/components/frontend/src/app/api/auth/sso/callback/route.ts +++ b/components/frontend/src/app/api/auth/sso/callback/route.ts @@ -43,7 +43,10 @@ export async function GET(request: NextRequest) { cookieStore.delete("oidc_state"); cookieStore.delete("oidc_return_to"); - return NextResponse.redirect(new URL(returnTo, request.nextUrl.origin)); + 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) { const message = err instanceof Error ? err.message : "Unknown error"; return NextResponse.json( diff --git a/components/frontend/src/app/api/auth/sso/login/route.ts b/components/frontend/src/app/api/auth/sso/login/route.ts index 5b9c4ad3e..ac590ca0e 100644 --- a/components/frontend/src/app/api/auth/sso/login/route.ts +++ b/components/frontend/src/app/api/auth/sso/login/route.ts @@ -1,5 +1,4 @@ import { NextRequest, NextResponse } from "next/server"; -import { cookies } from "next/headers"; import { buildAuthorizationUrl } from "@/lib/oidc"; export async function GET(request: NextRequest) { @@ -9,28 +8,17 @@ export async function GET(request: NextRequest) { const { url, codeVerifier, state } = await buildAuthorizationUrl(redirectUri); - const cookieStore = await cookies(); - cookieStore.set("oidc_code_verifier", codeVerifier, { + const response = NextResponse.redirect(url); + const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "lax", + sameSite: "lax" as const, path: "/", maxAge: 600, - }); - cookieStore.set("oidc_state", state, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 600, - }); - cookieStore.set("oidc_return_to", returnTo, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - 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 NextResponse.redirect(url); + return response; } diff --git a/components/frontend/src/lib/oidc.ts b/components/frontend/src/lib/oidc.ts index bc40b842f..20376426b 100644 --- a/components/frontend/src/lib/oidc.ts +++ b/components/frontend/src/lib/oidc.ts @@ -67,11 +67,70 @@ export async function exchangeCode( expiresAt: number; }> { const config = await getOIDCConfig(); - const tokens = await client.authorizationCodeGrant(config, callbackUrl, { - pkceCodeVerifier: codeVerifier, - expectedState, + + // In production, the browser and server use the same Keycloak URL, so the + // standard library flow works (full ID token validation including iss check). + // In dev (Kind), the browser reaches Keycloak via localhost:30090 while the + // server uses keycloak-service:8080 — the ID token iss claim won't match the + // discovery issuer. Fall back to a manual token exchange in that case. + const hasSplitUrls = !!process.env.SSO_PUBLIC_ISSUER_URL + && process.env.SSO_PUBLIC_ISSUER_URL !== process.env.SSO_ISSUER_URL; + + if (!hasSplitUrls) { + 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), + }; + } + + // Split-URL dev mode: manual token exchange (state + PKCE still validated) + const returnedState = callbackUrl.searchParams.get("state"); + if (returnedState !== expectedState) { + throw new Error("OIDC state mismatch"); + } + + const code = callbackUrl.searchParams.get("code"); + if (!code) { + throw new Error("Missing authorization code in callback"); + } + + const metadata = config.serverMetadata(); + const tokenEndpoint = String(metadata.token_endpoint); + const redirectUri = process.env.SSO_REDIRECT_URI || callbackUrl.origin + callbackUrl.pathname; + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: process.env.SSO_CLIENT_ID!, + client_secret: process.env.SSO_CLIENT_SECRET!, + code_verifier: codeVerifier, + }); + + const resp = await fetch(tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Token exchange failed (${resp.status}): ${text}`); + } + + const tokens = await resp.json() as { + access_token: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + }; + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? "", diff --git a/components/manifests/overlays/kind/backend-sso-patch.yaml b/components/manifests/overlays/kind/backend-sso-patch.yaml index f1e33ec35..f9b9acbb6 100644 --- a/components/manifests/overlays/kind/backend-sso-patch.yaml +++ b/components/manifests/overlays/kind/backend-sso-patch.yaml @@ -20,3 +20,5 @@ spec: name: sso-credentials key: SSO_AUDIENCE optional: true + - name: SSO_PUBLIC_ISSUER_URL + value: "http://localhost:30090/realms/ambient-code" diff --git a/components/manifests/overlays/kind/keycloak-deployment.yaml b/components/manifests/overlays/kind/keycloak-deployment.yaml index 1d6c7baa1..c707119ee 100644 --- a/components/manifests/overlays/kind/keycloak-deployment.yaml +++ b/components/manifests/overlays/kind/keycloak-deployment.yaml @@ -23,8 +23,8 @@ spec: - start-dev - --import-realm env: - - name: KC_HOSTNAME - value: "http://keycloak-service:8080" + - name: KC_HOSTNAME_STRICT + value: "false" - name: KC_HTTP_ENABLED value: "true" - name: KEYCLOAK_ADMIN From c2c98205bcfb2fcea4307af476befa237249669b Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 15:13:46 -0400 Subject: [PATCH 14/39] fix(frontend): add TTL to OIDC discovery cache to prevent stale endpoints The OIDC discovery config was cached as a module-level singleton with no expiry. If Keycloak restarted and got a new ClusterIP, token refresh calls would fail silently (ECONNREFUSED) and the session would be destroyed, logging the user out. Add a 5-minute TTL so the config is re-discovered periodically. This matches the Keycloak JWKS cache interval and ensures endpoint URLs stay current after dependency restarts. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/frontend/src/lib/oidc.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/frontend/src/lib/oidc.ts b/components/frontend/src/lib/oidc.ts index 20376426b..7c42a89f4 100644 --- a/components/frontend/src/lib/oidc.ts +++ b/components/frontend/src/lib/oidc.ts @@ -1,9 +1,14 @@ 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) return cachedConfig; + if (cachedConfig && Date.now() - cachedAt < DISCOVERY_TTL_MS) { + return cachedConfig; + } const issuerURL = process.env.SSO_ISSUER_URL; const clientId = process.env.SSO_CLIENT_ID; @@ -23,6 +28,7 @@ async function getOIDCConfig(): Promise { undefined, useInsecure ? { execute: [client.allowInsecureRequests] } : undefined, ); + cachedAt = Date.now(); return cachedConfig; } From 77548d644b6b63d4c4a0392775f988c0c14672a9 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 15:22:19 -0400 Subject: [PATCH 15/39] fix(backend): align RoleBinding subject with impersonation identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getUserSubjectFromContext now prefers userEmail (matching the Impersonate-User header) when creating RoleBindings. Previously it used userName (preferred_username), causing a mismatch: the RoleBinding subject would be "developer" but impersonation would use "developer@local.dev", so RBAC checks would fail. This ensures lazy RoleBinding creation in CreateProject works correctly with SSO impersonation — no manual RoleBindings needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/handlers/projects.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/components/backend/handlers/projects.go b/components/backend/handlers/projects.go index a84929f1d..41f1d874a 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,13 +941,17 @@ 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 } - if userID, exists := c.Get("userID"); exists && userID != nil { - return fmt.Sprintf("%v", userID), nil - } return "", fmt.Errorf("no user subject found in token") } From 1d6706b2168906202687705b00c5f7979385bfdb Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 15:29:05 -0400 Subject: [PATCH 16/39] fix(frontend): auto-retry on stale OIDC state and fix logout redirect - Callback route: redirect to /api/auth/sso/login instead of showing JSON error when OIDC state cookies are missing or exchange fails. Handles stale Keycloak sessions that skip the login page. - Logout route: derive post-logout redirect URI from SSO_REDIRECT_URI to avoid 0.0.0.0:3000 container address. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/app/api/auth/sso/callback/route.ts | 19 ++++++++++--------- .../src/app/api/auth/sso/logout/route.ts | 4 +++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/components/frontend/src/app/api/auth/sso/callback/route.ts b/components/frontend/src/app/api/auth/sso/callback/route.ts index 4e79f8a3f..46d3cb199 100644 --- a/components/frontend/src/app/api/auth/sso/callback/route.ts +++ b/components/frontend/src/app/api/auth/sso/callback/route.ts @@ -10,10 +10,9 @@ export async function GET(request: NextRequest) { const returnTo = cookieStore.get("oidc_return_to")?.value || "/"; if (!codeVerifier || !expectedState) { - return NextResponse.json( - { error: "Missing OIDC state — please try logging in again" }, - { status: 400 }, - ); + const loginUrl = new URL("/api/auth/sso/login", request.url); + loginUrl.searchParams.set("returnTo", returnTo); + return NextResponse.redirect(loginUrl); } try { @@ -48,10 +47,12 @@ export async function GET(request: NextRequest) { : request.nextUrl.origin; return NextResponse.redirect(new URL(returnTo, origin)); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json( - { error: "OIDC callback failed", detail: message }, - { status: 500 }, - ); + 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/logout/route.ts b/components/frontend/src/app/api/auth/sso/logout/route.ts index 89ede9a15..f8b625e5e 100644 --- a/components/frontend/src/app/api/auth/sso/logout/route.ts +++ b/components/frontend/src/app/api/auth/sso/logout/route.ts @@ -7,7 +7,9 @@ export async function GET(request: NextRequest) { const idToken = session.idToken || ""; session.destroy(); - const postLogoutRedirectUri = request.nextUrl.origin; + 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); From 03f1815807fc3444df8abbf7591a15a885b063d9 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 15:33:46 -0400 Subject: [PATCH 17/39] fix(frontend): use runtime ssoEnabled flag for logout redirect NEXT_PUBLIC_* env vars are inlined at build time in Next.js client components, so they're unavailable when the image is built without them. Instead, expose ssoEnabled from the /api/me server route and read it in the navigation component via useCurrentUser(). Co-Authored-By: Claude Opus 4.6 (1M context) --- components/frontend/src/app/api/me/route.ts | 1 + components/frontend/src/components/navigation.tsx | 4 +++- components/frontend/src/services/api/auth.ts | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/components/frontend/src/app/api/me/route.ts b/components/frontend/src/app/api/me/route.ts index 7418082a5..257dad176 100644 --- a/components/frontend/src/app/api/me/route.ts +++ b/components/frontend/src/app/api/me/route.ts @@ -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/components/navigation.tsx b/components/frontend/src/components/navigation.tsx index 281745fa7..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,11 +23,12 @@ 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 = () => { - if (process.env.NEXT_PUBLIC_SSO_ENABLED === 'true') { + if (me?.ssoEnabled) { window.location.href = '/api/auth/sso/logout'; } else { window.location.href = '/oauth/sign_out'; 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; }; /** From a32c4ea03866c27f122a45c9e2c13f79295ec1b0 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 15:41:42 -0400 Subject: [PATCH 18/39] fix(frontend): update project layout logout to use SSO when enabled The project layout had its own handleLogout hardcoded to /oauth/sign_out, separate from the main navigation. Unified both to use the runtime ssoEnabled flag from useCurrentUser(). Co-Authored-By: Claude Opus 4.6 (1M context) --- components/frontend/src/app/projects/[name]/layout.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 From c403b74eeced57550a43b55e8b25ac4525eb1102 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 15:51:04 -0400 Subject: [PATCH 19/39] feat(e2e): migrate test auth to Keycloak client_credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4 of SSO authentication migration (Phase 1): - Update extract-token.sh to obtain JWT from Keycloak via client_credentials grant (ambient-e2e client). Falls back to K8s SA token when Keycloak is not available. - Add audience and sub protocol mappers to ambient-e2e Keycloak client so tokens have proper aud claim for backend validation. - Add ClusterRoleBinding for e2e service account identity (service-account-ambient-e2e) so E2E tests can access projects. - No developer RoleBindings — JIT provisioning via CreateProject handles first-time access correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../overlays/kind/e2e-rolebinding.yaml | 14 +++++ .../overlays/kind/keycloak-realm.json | 22 ++++++++ .../overlays/kind/kustomization.yaml | 1 + e2e/scripts/extract-token.sh | 53 +++++++++++++------ 4 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 components/manifests/overlays/kind/e2e-rolebinding.yaml 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/keycloak-realm.json b/components/manifests/overlays/kind/keycloak-realm.json index b2ab337ea..6aa6ed55a 100644 --- a/components/manifests/overlays/kind/keycloak-realm.json +++ b/components/manifests/overlays/kind/keycloak-realm.json @@ -115,11 +115,33 @@ "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" + } + } ] } ], diff --git a/components/manifests/overlays/kind/kustomization.yaml b/components/manifests/overlays/kind/kustomization.yaml index 2f39f7b01..211bb72a4 100644 --- a/components/manifests/overlays/kind/kustomization.yaml +++ b/components/manifests/overlays/kind/kustomization.yaml @@ -21,6 +21,7 @@ resources: # SSO: Keycloak for local OIDC authentication - keycloak-deployment.yaml - sso-credentials.yaml +- e2e-rolebinding.yaml # Patches for e2e environment patches: diff --git a/e2e/scripts/extract-token.sh b/e2e/scripts/extract-token.sh index de17c8e0f..f6133694d 100755 --- a/e2e/scripts/extract-token.sh +++ b/e2e/scripts/extract-token.sh @@ -8,22 +8,45 @@ 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) +# Try Keycloak client_credentials first (SSO mode), fall back to K8s SA token 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 [ -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 - fi - sleep 2 -done +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}" + +# Check if Keycloak is available via a temporary pod +KEYCLOAK_TOKEN="" +if kubectl get svc keycloak-service -n ambient-code &>/dev/null; then + echo " Keycloak detected, obtaining 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 "") + KEYCLOAK_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token // empty' 2>/dev/null || echo "") +fi + +if [ -n "$KEYCLOAK_TOKEN" ]; then + TOKEN="$KEYCLOAK_TOKEN" + echo " Token obtained from Keycloak (client_credentials)" +else + echo " Keycloak not available, falling back to K8s SA 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 [ -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:-}" From a5ce781b5986448fcb7fb41232b5f1dc02111ed9 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 15:58:14 -0400 Subject: [PATCH 20/39] feat(frontend): add session expired dialog with global 401 detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the OIDC session expires and token refresh fails, the user now sees a blocking dialog instead of silent 401 errors: - Global 401 detection via QueryCache/MutationCache onError handlers - Skip retries on 401 to prevent request storms against the IdP - Non-dismissable AlertDialog with "Log in" button that preserves returnTo path so users land back on the same page - No "expiring soon" warning — server-side refresh handles access token renewal transparently; only surfaces when refresh token dies Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/providers/query-provider.tsx | 20 ++++++-- .../src/components/session-expired-dialog.tsx | 48 +++++++++++++++++++ components/frontend/src/lib/query-client.ts | 28 +++++++++-- 3 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 components/frontend/src/components/session-expired-dialog.tsx 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/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 }), }); } From fc58207bf2e5f97c5e1002db99d4214b88260a0a Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 16:19:14 -0400 Subject: [PATCH 21/39] fix(frontend): add refresh logging and fix middleware CORS on expiry - Add logging to getAccessToken so token refresh attempts and failures are visible in pod logs (was silently swallowing errors). - Fix middleware to return 401 JSON for RSC/fetch requests instead of redirecting to Keycloak. Cross-origin redirects fail as XHR and cause CORS errors. Full page navigations still redirect to login. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/frontend/src/lib/session.ts | 5 ++++- components/frontend/src/middleware.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/components/frontend/src/lib/session.ts b/components/frontend/src/lib/session.ts index 4e023fc5b..e9727c81f 100644 --- a/components/frontend/src/lib/session.ts +++ b/components/frontend/src/lib/session.ts @@ -37,6 +37,7 @@ export async function getAccessToken(): Promise { } 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; @@ -44,8 +45,10 @@ export async function getAccessToken(): Promise { 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 { + } 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 index d8496de0f..78070973a 100644 --- a/components/frontend/src/middleware.ts +++ b/components/frontend/src/middleware.ts @@ -10,6 +10,20 @@ export function middleware(request: NextRequest) { 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); From cc1d2c3d38db4035467f7b2ee3a9aa7bb7da7a78 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 16:31:13 -0400 Subject: [PATCH 22/39] fix(frontend): use manual token refresh in split-URL dev environments openid-client's refreshTokenGrant validates the ID token iss claim in the refresh response, which fails when the token was issued by localhost:30090 but the refresh goes through keycloak-service:8080. Use manual fetch to the token endpoint in split-URL mode (same approach as code exchange). Production uses the library's standard refreshTokenGrant with full validation. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/frontend/src/lib/oidc.ts | 41 ++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/components/frontend/src/lib/oidc.ts b/components/frontend/src/lib/oidc.ts index 7c42a89f4..711e81888 100644 --- a/components/frontend/src/lib/oidc.ts +++ b/components/frontend/src/lib/oidc.ts @@ -152,7 +152,46 @@ export async function refreshOIDCTokens(refreshToken: string): Promise<{ expiresAt: number; }> { const config = await getOIDCConfig(); - const tokens = await client.refreshTokenGrant(config, refreshToken); + const hasSplitUrls = !!process.env.SSO_PUBLIC_ISSUER_URL + && process.env.SSO_PUBLIC_ISSUER_URL !== process.env.SSO_ISSUER_URL; + + if (!hasSplitUrls) { + 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), + }; + } + + const metadata = config.serverMetadata(); + const tokenEndpoint = String(metadata.token_endpoint); + + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: process.env.SSO_CLIENT_ID!, + client_secret: process.env.SSO_CLIENT_SECRET!, + }); + + const resp = await fetch(tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Token refresh failed (${resp.status}): ${text}`); + } + + const tokens = await resp.json() as { + access_token: string; + refresh_token?: string; + id_token?: string; + expires_in?: number; + }; return { accessToken: tokens.access_token, From 86c190d7770a5867a10e1b51fdda277180454c2e Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 16:40:49 -0400 Subject: [PATCH 23/39] fix(security): proxy Keycloak through frontend to eliminate split URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split-URL problem (browser→localhost:30090, server→keycloak-service:8080) caused token issuer mismatches that broke refresh tokens and ID token validation. Every workaround added complexity. Root fix: proxy Keycloak through the frontend at /sso/* so browser and server both reach Keycloak through the same origin. Combined with KC_HOSTNAME=http://keycloak-service:8080, all tokens now have a consistent issuer that matches the discovery endpoint. Changes: - Add /sso/[...path] catch-all route that proxies to Keycloak, rewriting Location headers on redirects - Set KC_HOSTNAME to internal service URL for consistent token issuer - Update SSO_PUBLIC_ISSUER_URL to use the proxy path - Exclude /sso from auth middleware matcher - Remove unused next.config.js rewrites (build-time, not runtime) This eliminates: alt issuers on the backend, manual token exchange fallbacks, iss parameter remapping in callbacks, and CORS errors on session expiry redirects. Production deployments use a single URL and don't need the proxy. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../frontend/src/app/sso/[...path]/route.ts | 53 +++++++++++++++++++ components/frontend/src/middleware.ts | 2 +- .../overlays/kind/backend-sso-patch.yaml | 2 +- .../overlays/kind/frontend-test-patch.yaml | 2 +- .../overlays/kind/keycloak-deployment.yaml | 4 +- 5 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 components/frontend/src/app/sso/[...path]/route.ts 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/middleware.ts b/components/frontend/src/middleware.ts index 78070973a..0d19e2ab7 100644 --- a/components/frontend/src/middleware.ts +++ b/components/frontend/src/middleware.ts @@ -31,6 +31,6 @@ export function middleware(request: NextRequest) { export const config = { matcher: [ - "/((?!api|_next|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map)).*)", + "/((?!api|sso|_next|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map)).*)", ], }; diff --git a/components/manifests/overlays/kind/backend-sso-patch.yaml b/components/manifests/overlays/kind/backend-sso-patch.yaml index f9b9acbb6..4770d27da 100644 --- a/components/manifests/overlays/kind/backend-sso-patch.yaml +++ b/components/manifests/overlays/kind/backend-sso-patch.yaml @@ -21,4 +21,4 @@ spec: key: SSO_AUDIENCE optional: true - name: SSO_PUBLIC_ISSUER_URL - value: "http://localhost:30090/realms/ambient-code" + value: "http://localhost:11646/sso/realms/ambient-code" diff --git a/components/manifests/overlays/kind/frontend-test-patch.yaml b/components/manifests/overlays/kind/frontend-test-patch.yaml index 394e18176..eb71d47fd 100644 --- a/components/manifests/overlays/kind/frontend-test-patch.yaml +++ b/components/manifests/overlays/kind/frontend-test-patch.yaml @@ -63,4 +63,4 @@ spec: - name: SSO_REDIRECT_URI value: "http://localhost:11646/api/auth/sso/callback" - name: SSO_PUBLIC_ISSUER_URL - value: "http://localhost:30090/realms/ambient-code" + value: "http://localhost:11646/sso/realms/ambient-code" diff --git a/components/manifests/overlays/kind/keycloak-deployment.yaml b/components/manifests/overlays/kind/keycloak-deployment.yaml index c707119ee..1d6c7baa1 100644 --- a/components/manifests/overlays/kind/keycloak-deployment.yaml +++ b/components/manifests/overlays/kind/keycloak-deployment.yaml @@ -23,8 +23,8 @@ spec: - start-dev - --import-realm env: - - name: KC_HOSTNAME_STRICT - value: "false" + - name: KC_HOSTNAME + value: "http://keycloak-service:8080" - name: KC_HTTP_ENABLED value: "true" - name: KEYCLOAK_ADMIN From b5355cbcdf9786a7cdbaba2efc9489ece7cbf339 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 17:01:01 -0400 Subject: [PATCH 24/39] fix(security): use KC_HOSTNAME_BACKCHANNEL_DYNAMIC for consistent issuer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the split-URL issuer mismatch by properly configuring Keycloak's hostname-backchannel-dynamic feature: - KC_HOSTNAME=http://localhost:11646/sso — all tokens use the public URL as issuer, login pages render with proxy URLs - KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true — internal services get backchannel URLs (token_endpoint, jwks_uri) via keycloak-service:8080 Frontend changes: - Manual OIDC discovery to bypass openid-client v6's issuer validation (known issue: github.com/panva/openid-client/issues/737) - Remove all split-URL workarounds (manual token exchange, iss remapping, URL rewriting in auth/logout/refresh) - openid-client's standard authorizationCodeGrant and refreshTokenGrant now work correctly for all flows Backend changes: - JWT validator uses discovered issuer from OIDC metadata (not the discovery URL) so it accepts the public issuer in tokens Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/jwtauth/validator.go | 27 ++-- components/backend/jwtauth/validator_test.go | 7 +- .../src/app/api/auth/sso/callback/route.ts | 7 - components/frontend/src/lib/oidc.ts | 145 +++--------------- .../overlays/kind/keycloak-deployment.yaml | 6 +- .../overlays/kind/sso-credentials.yaml | 1 + 6 files changed, 50 insertions(+), 143 deletions(-) diff --git a/components/backend/jwtauth/validator.go b/components/backend/jwtauth/validator.go index 412698554..62f885f74 100644 --- a/components/backend/jwtauth/validator.go +++ b/components/backend/jwtauth/validator.go @@ -34,7 +34,7 @@ func NewValidator(issuerURL, audience string) (*Validator, error) { return nil, fmt.Errorf("issuer URL is required") } - jwksURL, err := discoverJWKSURL(issuerURL) + discoveredIssuer, jwksURL, err := discoverOIDCConfig(issuerURL) if err != nil { return nil, fmt.Errorf("OIDC discovery failed: %w", err) } @@ -45,7 +45,6 @@ func NewValidator(issuerURL, audience string) (*Validator, error) { return nil, fmt.Errorf("failed to register JWKS URL: %w", err) } - // Pre-fetch keys to fail fast on misconfiguration if _, err := cache.Refresh(ctx, jwksURL); err != nil { return nil, fmt.Errorf("failed to fetch JWKS: %w", err) } @@ -53,7 +52,7 @@ func NewValidator(issuerURL, audience string) (*Validator, error) { return &Validator{ jwksCache: cache, jwksURL: jwksURL, - issuer: issuerURL, + issuer: discoveredIssuer, audience: audience, }, nil } @@ -163,30 +162,36 @@ func (v *Validator) isAcceptedIssuer(iss string) bool { return false } -func discoverJWKSURL(issuerURL string) (string, error) { +func discoverOIDCConfig(issuerURL string) (discoveredIssuer string, jwksURL string, err error) { wellKnownURL := issuerURL + "/.well-known/openid-configuration" - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(wellKnownURL) + 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) + 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) + 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) + return "", "", fmt.Errorf("failed to decode OIDC configuration: %w", err) } if config.JWKSURI == "" { - return "", fmt.Errorf("OIDC configuration missing jwks_uri") + return "", "", fmt.Errorf("OIDC configuration missing jwks_uri") } - return config.JWKSURI, nil + 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 index c94bd05ac..f4ca0776f 100644 --- a/components/backend/jwtauth/validator_test.go +++ b/components/backend/jwtauth/validator_test.go @@ -304,9 +304,12 @@ func TestDiscoverJWKSURL(t *testing.T) { defer server.Close() serverURL = server.URL - jwksURL, err := discoverJWKSURL(server.URL) + issuer, jwksURL, err := discoverOIDCConfig(server.URL) if err != nil { - t.Fatalf("discoverJWKSURL: %v", err) + 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 { diff --git a/components/frontend/src/app/api/auth/sso/callback/route.ts b/components/frontend/src/app/api/auth/sso/callback/route.ts index 46d3cb199..5250f13c2 100644 --- a/components/frontend/src/app/api/auth/sso/callback/route.ts +++ b/components/frontend/src/app/api/auth/sso/callback/route.ts @@ -23,13 +23,6 @@ export async function GET(request: NextRequest) { callbackUrl.searchParams.set(key, value); }); - // Keycloak sends iss matching KC_HOSTNAME (internal URL). If it somehow - // doesn't match (e.g., KC_HOSTNAME not set), remap to the configured issuer. - const internalIssuer = process.env.SSO_ISSUER_URL; - const callbackIss = callbackUrl.searchParams.get("iss"); - if (internalIssuer && callbackIss && callbackIss !== internalIssuer) { - callbackUrl.searchParams.set("iss", internalIssuer); - } const tokens = await exchangeCode(callbackUrl, codeVerifier, expectedState); const session = await getSession(); session.accessToken = tokens.accessToken; diff --git a/components/frontend/src/lib/oidc.ts b/components/frontend/src/lib/oidc.ts index 711e81888..c6e149c67 100644 --- a/components/frontend/src/lib/oidc.ts +++ b/components/frontend/src/lib/oidc.ts @@ -21,13 +21,27 @@ async function getOIDCConfig(): Promise { const serverUrl = new URL(issuerURL); const useInsecure = serverUrl.protocol === "http:"; - cachedConfig = await client.discovery( - serverUrl, + // 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, - undefined, - useInsecure ? { execute: [client.allowInsecureRequests] } : undefined, ); + + if (useInsecure) { + client.allowInsecureRequests(cachedConfig); + } + cachedAt = Date.now(); return cachedConfig; } @@ -50,15 +64,6 @@ export async function buildAuthorizationUrl(redirectUri: string): Promise<{ state, }); - // In Kind/dev, the browser needs to reach Keycloak via an external URL - // (e.g., localhost:30090) while the server uses the internal cluster URL. - const publicIssuer = process.env.SSO_PUBLIC_ISSUER_URL; - if (publicIssuer) { - const internalIssuer = process.env.SSO_ISSUER_URL || ""; - const url = redirectTo.href.replace(internalIssuer, publicIssuer); - return { url, codeVerifier, state }; - } - return { url: redirectTo.href, codeVerifier, state }; } @@ -73,70 +78,11 @@ export async function exchangeCode( expiresAt: number; }> { const config = await getOIDCConfig(); - - // In production, the browser and server use the same Keycloak URL, so the - // standard library flow works (full ID token validation including iss check). - // In dev (Kind), the browser reaches Keycloak via localhost:30090 while the - // server uses keycloak-service:8080 — the ID token iss claim won't match the - // discovery issuer. Fall back to a manual token exchange in that case. - const hasSplitUrls = !!process.env.SSO_PUBLIC_ISSUER_URL - && process.env.SSO_PUBLIC_ISSUER_URL !== process.env.SSO_ISSUER_URL; - - if (!hasSplitUrls) { - 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), - }; - } - - // Split-URL dev mode: manual token exchange (state + PKCE still validated) - const returnedState = callbackUrl.searchParams.get("state"); - if (returnedState !== expectedState) { - throw new Error("OIDC state mismatch"); - } - - const code = callbackUrl.searchParams.get("code"); - if (!code) { - throw new Error("Missing authorization code in callback"); - } - - const metadata = config.serverMetadata(); - const tokenEndpoint = String(metadata.token_endpoint); - const redirectUri = process.env.SSO_REDIRECT_URI || callbackUrl.origin + callbackUrl.pathname; - - const body = new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - client_id: process.env.SSO_CLIENT_ID!, - client_secret: process.env.SSO_CLIENT_SECRET!, - code_verifier: codeVerifier, - }); - - const resp = await fetch(tokenEndpoint, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), + const tokens = await client.authorizationCodeGrant(config, callbackUrl, { + pkceCodeVerifier: codeVerifier, + expectedState, }); - if (!resp.ok) { - const text = await resp.text(); - throw new Error(`Token exchange failed (${resp.status}): ${text}`); - } - - const tokens = await resp.json() as { - access_token: string; - refresh_token?: string; - id_token?: string; - expires_in?: number; - }; - return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? "", @@ -152,46 +98,7 @@ export async function refreshOIDCTokens(refreshToken: string): Promise<{ expiresAt: number; }> { const config = await getOIDCConfig(); - const hasSplitUrls = !!process.env.SSO_PUBLIC_ISSUER_URL - && process.env.SSO_PUBLIC_ISSUER_URL !== process.env.SSO_ISSUER_URL; - - if (!hasSplitUrls) { - 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), - }; - } - - const metadata = config.serverMetadata(); - const tokenEndpoint = String(metadata.token_endpoint); - - const body = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: process.env.SSO_CLIENT_ID!, - client_secret: process.env.SSO_CLIENT_SECRET!, - }); - - const resp = await fetch(tokenEndpoint, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - - if (!resp.ok) { - const text = await resp.text(); - throw new Error(`Token refresh failed (${resp.status}): ${text}`); - } - - const tokens = await resp.json() as { - access_token: string; - refresh_token?: string; - id_token?: string; - expires_in?: number; - }; + const tokens = await client.refreshTokenGrant(config, refreshToken); return { accessToken: tokens.access_token, @@ -208,13 +115,7 @@ export async function getEndSessionUrl(idTokenHint: string, postLogoutRedirectUr if (!endSessionEndpoint) { return postLogoutRedirectUri; } - let endSessionUrl = String(endSessionEndpoint); - const publicIssuer = process.env.SSO_PUBLIC_ISSUER_URL; - const internalIssuer = process.env.SSO_ISSUER_URL || ""; - if (publicIssuer && internalIssuer) { - endSessionUrl = endSessionUrl.replace(internalIssuer, publicIssuer); - } - const url = new URL(endSessionUrl); + 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/manifests/overlays/kind/keycloak-deployment.yaml b/components/manifests/overlays/kind/keycloak-deployment.yaml index 1d6c7baa1..9f4897184 100644 --- a/components/manifests/overlays/kind/keycloak-deployment.yaml +++ b/components/manifests/overlays/kind/keycloak-deployment.yaml @@ -24,7 +24,11 @@ spec: - --import-realm env: - name: KC_HOSTNAME - value: "http://keycloak-service:8080" + value: "http://localhost:11646/sso" + - name: KC_HOSTNAME_BACKCHANNEL_DYNAMIC + value: "true" + - name: KC_PROXY_HEADERS + value: "xforwarded" - name: KC_HTTP_ENABLED value: "true" - name: KEYCLOAK_ADMIN diff --git a/components/manifests/overlays/kind/sso-credentials.yaml b/components/manifests/overlays/kind/sso-credentials.yaml index 11c686bcd..5b6cc20bc 100644 --- a/components/manifests/overlays/kind/sso-credentials.yaml +++ b/components/manifests/overlays/kind/sso-credentials.yaml @@ -7,6 +7,7 @@ metadata: type: Opaque stringData: SSO_ISSUER_URL: "http://keycloak-service:8080/realms/ambient-code" + SSO_FRONTEND_ISSUER_URL: "http://localhost:11646/sso/realms/ambient-code" SSO_CLIENT_ID: "ambient-frontend" SSO_CLIENT_SECRET: "dev-secret-do-not-use-in-prod" SSO_AUDIENCE: "ambient-frontend" From a5d843acd295620e1dbd9fb7eb1b48ff7eebe011 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 17:29:02 -0400 Subject: [PATCH 25/39] docs: update dev environment docs for Keycloak SSO authentication - Frontend README: replace OC_TOKEN/OAuth proxy header docs with SSO env vars and OIDC session model description - Frontend .env.example: add SSO_* vars, move OC_* to legacy section - Backend README: replace DISABLE_AUTH migration guide with Keycloak dev auth instructions (JWT and SA token examples) - E2E README: update quick start to use extract-token.sh (Keycloak client_credentials with K8s SA fallback) - Kind dev guide: add Keycloak to bootstrap steps, document dev credentials and session lifetimes - CONTRIBUTING.md: add Keycloak to kind-up description, update access instructions with login info - OPENSHIFT_OAUTH.md: mark as legacy, link to SSO spec Co-Authored-By: Claude Opus 4.6 (1M context) --- CONTRIBUTING.md | 8 +- components/backend/README.md | 90 ++++++------------- components/frontend/.env.example | 22 +++-- components/frontend/README.md | 50 ++++------- docs/internal/deployment/OPENSHIFT_OAUTH.md | 2 + .../developer/local-development/kind.md | 16 ++-- e2e/README.md | 13 ++- 7 files changed, 81 insertions(+), 120 deletions(-) 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/components/backend/README.md b/components/backend/README.md index 9510aa5d8..9e21a0c85 100644 --- a/components/backend/README.md +++ b/components/backend/README.md @@ -34,86 +34,48 @@ 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: -#### 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 +#### Local development (Kind) -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 +`make kind-up` deploys Keycloak automatically. The backend is configured with: +- `SSO_ISSUER_URL` — points to the in-cluster Keycloak +- `SSO_AUDIENCE` — `ambient-frontend` -#### Option A: OpenShift / CRC (recommended for this repo) +To test backend endpoints directly with a Keycloak JWT: ```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 +# 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 ``` -#### Option B: kind (ServiceAccount token for local dev) - -Kubernetes v1.24+ supports `kubectl create token`: +K8s ServiceAccount tokens also work (dual-path auth): ```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: - -```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)" -``` - -#### Calling project-scoped APIs (example) - -```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/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/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..2ac74b49f 100755 --- a/docs/internal/developer/local-development/kind.md +++ b/docs/internal/developer/local-development/kind.md @@ -72,14 +72,20 @@ 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:** +- The frontend redirects to Keycloak for login automatically +- Dev credentials: `developer` / `developer` (or `admin` / `admin`) +- Keycloak admin console: port-forward `svc/keycloak-service 30090:8080` +- Sessions last 30 minutes; access tokens refresh silently every 5 minutes ### `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 ``` From ef5c611bbac5aaea32876f60a5a1567b9a05702c Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 17:32:26 -0400 Subject: [PATCH 26/39] docs: clarify sso-authentication is an infrastructure flag, not user-facing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sso-authentication Unleash flag controls which auth path the backend uses. It is not visible in workspace settings and is not user-configurable — ops enables it per-environment during migration. Kind dev cluster creates and enables it automatically. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/README.md | 4 +++- specs/security/sso-authentication.spec.md | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/components/backend/README.md b/components/backend/README.md index 9e21a0c85..1ca9d5699 100644 --- a/components/backend/README.md +++ b/components/backend/README.md @@ -36,12 +36,14 @@ make dev ### Authentication -The backend supports two auth modes, controlled by the `sso-authentication` Unleash feature flag: +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. **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. **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). +In the Kind dev cluster, this flag is created and enabled automatically during bootstrap. + #### Local development (Kind) `make kind-up` deploys Keycloak automatically. The backend is configured with: diff --git a/specs/security/sso-authentication.spec.md b/specs/security/sso-authentication.spec.md index 62cb666c6..dc99d8535 100644 --- a/specs/security/sso-authentication.spec.md +++ b/specs/security/sso-authentication.spec.md @@ -398,9 +398,13 @@ In Kind environments, E2E tests SHALL use the local Keycloak instance. ### Requirement: Feature-Flagged Migration The transition from OAuth proxy to SSO authentication SHALL be gated behind a feature -flag. During migration, the platform SHALL support both authentication modes -simultaneously. The feature flag SHALL control which authentication path is active -per deployment. +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) From 27c0db5d2ecad18eead71ca9eded2ef3e2e170f0 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 17:39:19 -0400 Subject: [PATCH 27/39] feat: add make kind-sso-toggle to switch auth modes in Kind Toggles SSO on/off for both frontend (SSO_ENABLED env var) and backend (sso-authentication Unleash flag) in a single command. Legacy mode (SA token auth) is the default after kind-up; run kind-sso-toggle to enable Keycloak OIDC. Also updates Kind dev guide and backend README to document the toggle and clarify that legacy mode is the default. Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 26 ++++++++++++++++++- components/backend/README.md | 2 +- .../developer/local-development/kind.md | 15 ++++++++--- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 48d6f045b..d7b7bc33f 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,30 @@ 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)..."; \ + kubectl set env deployment/frontend -n $(NAMESPACE) SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true; \ + 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. Frontend will redirect to Keycloak login."; \ + fi + @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Waiting for frontend rollout..." + @kubectl rollout status deployment/frontend -n $(NAMESPACE) --timeout=60s >/dev/null 2>&1 + @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 1ca9d5699..03b4eede3 100644 --- a/components/backend/README.md +++ b/components/backend/README.md @@ -42,7 +42,7 @@ The backend supports two auth modes, controlled by the `sso-authentication` Unle **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). -In the Kind dev cluster, this flag is created and enabled automatically during bootstrap. +In the Kind dev cluster, legacy mode is the default. Toggle SSO on/off with `make kind-sso-toggle` (affects both frontend and backend). #### Local development (Kind) diff --git a/docs/internal/developer/local-development/kind.md b/docs/internal/developer/local-development/kind.md index 2ac74b49f..d8ff146b7 100755 --- a/docs/internal/developer/local-development/kind.md +++ b/docs/internal/developer/local-development/kind.md @@ -82,10 +82,19 @@ Creates kind cluster and deploys platform with Quay.io images. - Backend: `http://localhost:` **Authentication:** -- The frontend redirects to Keycloak for login automatically -- Dev credentials: `developer` / `developer` (or `admin` / `admin`) -- Keycloak admin console: port-forward `svc/keycloak-service 30090:8080` +- 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` From 6864324f4efe6681d5d09cc7eb0aea0488bdb91c Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 17:42:32 -0400 Subject: [PATCH 28/39] style(backend): fix gofmt formatting in SSO files Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/handlers/sso.go | 1 - components/backend/jwtauth/validator.go | 12 ++++++------ components/backend/jwtauth/validator_test.go | 8 ++++---- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/components/backend/handlers/sso.go b/components/backend/handlers/sso.go index 945c03fc3..0a0ee0a0f 100644 --- a/components/backend/handlers/sso.go +++ b/components/backend/handlers/sso.go @@ -122,4 +122,3 @@ func setIdentityFromTokenReview(c *gin.Context, userName string, groups []string c.Set("authIdentity", userName) } - diff --git a/components/backend/jwtauth/validator.go b/components/backend/jwtauth/validator.go index 62f885f74..687103053 100644 --- a/components/backend/jwtauth/validator.go +++ b/components/backend/jwtauth/validator.go @@ -14,7 +14,7 @@ import ( type Claims struct { Sub string Email string - PreferredUsername string + PreferredUsername string Groups []string Issuer string Audience []string @@ -22,11 +22,11 @@ type Claims struct { } type Validator struct { - jwksCache *jwk.Cache - jwksURL string - issuer string - altIssuers []string - audience string + jwksCache *jwk.Cache + jwksURL string + issuer string + altIssuers []string + audience string } func NewValidator(issuerURL, audience string) (*Validator, error) { diff --git a/components/backend/jwtauth/validator_test.go b/components/backend/jwtauth/validator_test.go index f4ca0776f..bae3d205a 100644 --- a/components/backend/jwtauth/validator_test.go +++ b/components/backend/jwtauth/validator_test.go @@ -96,9 +96,9 @@ func TestValidate(t *testing.T) { } tests := []struct { - name string - buildToken func() string - wantErr bool + name string + buildToken func() string + wantErr bool checkClaims func(*testing.T, *Claims) }{ { @@ -108,7 +108,7 @@ func TestValidate(t *testing.T) { Subject("f:abc:jsell"). Issuer(server.URL). Audience([]string{"ambient-frontend"}). - Expiration(time.Now().Add(5 * time.Minute)). + Expiration(time.Now().Add(5*time.Minute)). IssuedAt(time.Now()). Claim("email", "jsell@redhat.com"). Claim("preferred_username", "jsell"). From c5eb74955022c9e39e17fac75c8e97e27e7725f5 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 17:47:03 -0400 Subject: [PATCH 29/39] fix(frontend): resolve useEffect missing dependency lint warning Extract fileTabs.updateTaskStatus to a const so the useEffect dependency array references the stable callback directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/app/projects/[name]/sessions/[sessionName]/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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) { From 4b7ab9f4a81cde5e915b65e6d0a44f0a61dedb9b Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 17:58:18 -0400 Subject: [PATCH 30/39] fix(backend): add package comment to jwtauth for staticcheck ST1000 Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/jwtauth/validator.go | 1 + 1 file changed, 1 insertion(+) diff --git a/components/backend/jwtauth/validator.go b/components/backend/jwtauth/validator.go index 687103053..1a8a76889 100644 --- a/components/backend/jwtauth/validator.go +++ b/components/backend/jwtauth/validator.go @@ -1,3 +1,4 @@ +// Package jwtauth provides JWT validation against OIDC providers using JWKS. package jwtauth import ( From da361b8ff30814c45d4feabceac8d48fb9c2c130 Mon Sep 17 00:00:00 2001 From: John Sell Date: Thu, 14 May 2026 18:08:19 -0400 Subject: [PATCH 31/39] fix(backend): unflake 3 project handler tests and add userID fallback Two fixes: - models_test.go captured K8sClientMw/DynamicClient at module load time (when nil), then restored them to nil in AfterEach, poisoning subsequent tests. Move capture to BeforeEach so values are saved after SetupHandlerDependencies runs. - getUserSubjectFromContext now falls back to userID context value (set by SetTestToken in tests) after checking userEmail, userIDOriginal, and userName. This ensures tests that only set userID still work. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/handlers/models_test.go | 14 ++++++++++---- components/backend/handlers/projects.go | 3 +++ 2 files changed, 13 insertions(+), 4 deletions(-) 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 41f1d874a..a059c067a 100644 --- a/components/backend/handlers/projects.go +++ b/components/backend/handlers/projects.go @@ -952,6 +952,9 @@ func getUserSubjectFromContext(c *gin.Context) (string, error) { if userName, exists := c.Get("userName"); exists && userName != nil { return fmt.Sprintf("%v", userName), nil } + if userID, exists := c.Get("userID"); exists && userID != nil { + return fmt.Sprintf("%v", userID), nil + } return "", fmt.Errorf("no user subject found in token") } From 03fdfb0824c50e2b838cec28c4199768da5bbbb2 Mon Sep 17 00:00:00 2001 From: jsell-rh Date: Fri, 15 May 2026 10:57:21 +0000 Subject: [PATCH 32/39] fix(auth): enable dual-auth in SSO mode for E2E tests and API clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildForwardHeadersSSO now falls back to Bearer token from request when no SSO session cookie exists. This enables: - SSO users: session cookie → JWT forwarded - E2E tests / API clients: Bearer token in request → forwarded directly Also adds Keycloak to wait-for-ready.sh to prevent race conditions where frontend starts before Keycloak is ready. Co-Authored-By: Claude Opus 4.5 --- components/frontend/src/lib/auth.ts | 10 +++++++++- e2e/scripts/wait-for-ready.sh | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/components/frontend/src/lib/auth.ts b/components/frontend/src/lib/auth.ts index 0e553d1d3..dc0901269 100644 --- a/components/frontend/src/lib/auth.ts +++ b/components/frontend/src/lib/auth.ts @@ -172,7 +172,15 @@ async function buildForwardHeadersSSO(request: Request, extra?: Record/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 "" From aa3e87072a2b6097c7c463cdae79068a3e515b68 Mon Sep 17 00:00:00 2001 From: jsell-rh Date: Fri, 15 May 2026 11:17:20 +0000 Subject: [PATCH 33/39] fix(keycloak): add preferred_username claim to E2E client tokens The ambient-e2e service account JWT was missing the preferred_username claim, causing the backend to fall back to the 'sub' claim (a UUID) for K8s impersonation. The RBAC expects 'service-account-ambient-e2e'. This adds a protocol mapper to include the service account's username in JWT tokens, enabling proper identity mapping for E2E tests in SSO mode. Co-Authored-By: Claude Opus 4.5 --- .../manifests/overlays/kind/keycloak-realm.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/components/manifests/overlays/kind/keycloak-realm.json b/components/manifests/overlays/kind/keycloak-realm.json index 6aa6ed55a..0c72cc332 100644 --- a/components/manifests/overlays/kind/keycloak-realm.json +++ b/components/manifests/overlays/kind/keycloak-realm.json @@ -141,6 +141,21 @@ "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" + } } ] } From 755d5d0690b22509a1f1b0bd46b45d7c443761c6 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 15 May 2026 09:25:53 -0400 Subject: [PATCH 34/39] fix(e2e): default to legacy auth in Kind, use K8s SA token for E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The E2E tests failed because: 1. frontend-test-patch.yaml hardcoded SSO_ENABLED=true, but the backend SSO flag was off — causing Keycloak JWTs to be sent to K8s API which rejected them 2. extract-token.sh preferred Keycloak tokens, but the backend wasn't configured to validate them Fixes: - Set SSO_ENABLED=false by default in the Kind overlay. Use `make kind-sso-toggle` to enable SSO explicitly. - extract-token.sh now defaults to K8s SA token (works in both modes). Set E2E_USE_SSO=true to use Keycloak client_credentials instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../overlays/kind/frontend-test-patch.yaml | 6 ++-- e2e/scripts/extract-token.sh | 31 ++++++++++--------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/components/manifests/overlays/kind/frontend-test-patch.yaml b/components/manifests/overlays/kind/frontend-test-patch.yaml index eb71d47fd..f0d94c9cf 100644 --- a/components/manifests/overlays/kind/frontend-test-patch.yaml +++ b/components/manifests/overlays/kind/frontend-test-patch.yaml @@ -31,11 +31,11 @@ spec: value: "system:serviceaccount:ambient-code:test-user" - name: OC_EMAIL value: "test-user@vteam.local" - # SSO/OIDC configuration + # SSO/OIDC configuration (disabled by default; enable with make kind-sso-toggle) - name: SSO_ENABLED - value: "true" + value: "false" - name: NEXT_PUBLIC_SSO_ENABLED - value: "true" + value: "false" - name: SSO_ISSUER_URL valueFrom: secretKeyRef: diff --git a/e2e/scripts/extract-token.sh b/e2e/scripts/extract-token.sh index f6133694d..173f6ddf0 100755 --- a/e2e/scripts/extract-token.sh +++ b/e2e/scripts/extract-token.sh @@ -8,17 +8,17 @@ echo "Extracting test user token..." # Cluster name (override via env var for multi-worktree support) KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-ambient-local}" -# Try Keycloak client_credentials first (SSO mode), fall back to K8s SA token +# 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="" -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}" -# Check if Keycloak is available via a temporary pod -KEYCLOAK_TOKEN="" -if kubectl get svc keycloak-service -n ambient-code &>/dev/null; then - echo " Keycloak detected, obtaining token via client_credentials..." +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 \ @@ -26,14 +26,15 @@ if kubectl get svc keycloak-service -n ambient-code &>/dev/null; then -d client_secret=${E2E_CLIENT_SECRET} \ -d grant_type=client_credentials \ -d scope=openid" 2>/dev/null || echo "") - KEYCLOAK_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token // empty' 2>/dev/null || echo "") + TOKEN=$(echo "$RESPONSE" | jq -r '.access_token // empty' 2>/dev/null || echo "") + if [ -n "$TOKEN" ]; then + echo " Token obtained from Keycloak (client_credentials)" + else + echo " Keycloak token fetch failed, falling back to K8s SA token..." + fi fi -if [ -n "$KEYCLOAK_TOKEN" ]; then - TOKEN="$KEYCLOAK_TOKEN" - echo " Token obtained from Keycloak (client_credentials)" -else - echo " Keycloak not available, falling back to K8s SA token..." +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 From 93dbf3987f6867e1dd9f90669b921fd78c8689ec Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 15 May 2026 09:54:11 -0400 Subject: [PATCH 35/39] feat(e2e): run E2E tests in both legacy and SSO auth modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second E2E pass that enables SSO (frontend env + Unleash flag), re-extracts a Keycloak JWT via client_credentials, and runs the full Cypress suite again. This ensures both auth paths are exercised in CI. The SSO pass reuses the same Kind cluster — just toggles the auth mode, restarts affected deployments, and re-runs tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/e2e.yml | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7b7e31ef6..ee53a69c8 100755 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -242,10 +242,45 @@ 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: | + # 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 + + - 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 + ./scripts/run-tests.sh - name: Upload test results if: failure() From b5902761fb7b9259e7581d4890570790bf1eb03f Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 15 May 2026 10:41:11 -0400 Subject: [PATCH 36/39] fix(e2e): add E2E login route for SSO session cookie injection cy.visit() page navigations can't carry custom headers, so the SSO middleware redirects to Keycloak which Cypress can't handle. Fix by adding /api/auth/sso/e2e-login route that accepts a token and creates a session cookie (non-production only). Changes: - New /api/auth/sso/e2e-login POST route: accepts {token}, creates iron-session cookie. Returns 404 in production. - Cypress beforeEach: calls e2e-login route when SSO_MODE is true to create session cookie before page visits. - cypress.config.ts: passes E2E_USE_SSO env var as SSO_MODE to tests. - e2e.yml: adds frontend health check before SSO E2E pass. - Revert middleware Bearer token check (doesn't help for navigations). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/e2e.yml | 14 +++++++++ .../src/app/api/auth/sso/e2e-login/route.ts | 22 ++++++++++++++ e2e/cypress.config.ts | 1 + e2e/cypress/support/commands.ts | 30 ++++++++++++------- 4 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 components/frontend/src/app/api/auth/sso/e2e-login/route.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ee53a69c8..614ad723d 100755 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -280,6 +280,20 @@ jobs: 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 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..cd78ab44d --- /dev/null +++ b/components/frontend/src/app/api/auth/sso/e2e-login/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/session"; + +export async function POST(request: NextRequest) { + if (process.env.NODE_ENV === "production") { + 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/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 From 434086842d79c17935ea01f3709c49dde6b300ef Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 15 May 2026 12:03:04 -0400 Subject: [PATCH 37/39] fix(e2e): fix SSO E2E by using port 80 and E2E_TEST_HELPERS guard Two root causes for SSO E2E failures: 1. The e2e-login route checked NODE_ENV !== "production", but the Docker image sets NODE_ENV=production. Changed to check E2E_TEST_HELPERS env var (opt-in, added to Kind overlay). 2. All SSO public URLs used port 11646 (port-forward for local dev), but CI uses NodePort on port 80. Changed KC_HOSTNAME, SSO_REDIRECT_URI, and SSO_PUBLIC_ISSUER_URL to use http://localhost (no port = port 80). Also added Keycloak readiness check and backend JWT validator verification to the CI toggle step. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/e2e.yml | 21 +++++++++++++++++++ .../src/app/api/auth/sso/e2e-login/route.ts | 5 ++++- .../overlays/kind/backend-sso-patch.yaml | 2 +- .../overlays/kind/frontend-test-patch.yaml | 7 +++++-- .../overlays/kind/keycloak-deployment.yaml | 2 +- .../overlays/kind/sso-credentials.yaml | 2 +- 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 614ad723d..2969cb459 100755 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -248,8 +248,13 @@ jobs: - 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 & @@ -267,12 +272,28 @@ jobs: 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: 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 index cd78ab44d..3ca34435f 100644 --- a/components/frontend/src/app/api/auth/sso/e2e-login/route.ts +++ b/components/frontend/src/app/api/auth/sso/e2e-login/route.ts @@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/session"; export async function POST(request: NextRequest) { - if (process.env.NODE_ENV === "production") { + // 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 }); } diff --git a/components/manifests/overlays/kind/backend-sso-patch.yaml b/components/manifests/overlays/kind/backend-sso-patch.yaml index 4770d27da..a236f5202 100644 --- a/components/manifests/overlays/kind/backend-sso-patch.yaml +++ b/components/manifests/overlays/kind/backend-sso-patch.yaml @@ -21,4 +21,4 @@ spec: key: SSO_AUDIENCE optional: true - name: SSO_PUBLIC_ISSUER_URL - value: "http://localhost:11646/sso/realms/ambient-code" + value: "http://localhost/sso/realms/ambient-code" diff --git a/components/manifests/overlays/kind/frontend-test-patch.yaml b/components/manifests/overlays/kind/frontend-test-patch.yaml index f0d94c9cf..b4bff05f3 100644 --- a/components/manifests/overlays/kind/frontend-test-patch.yaml +++ b/components/manifests/overlays/kind/frontend-test-patch.yaml @@ -31,6 +31,9 @@ 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" @@ -61,6 +64,6 @@ spec: key: SESSION_SECRET optional: true - name: SSO_REDIRECT_URI - value: "http://localhost:11646/api/auth/sso/callback" + value: "http://localhost/api/auth/sso/callback" - name: SSO_PUBLIC_ISSUER_URL - value: "http://localhost:11646/sso/realms/ambient-code" + 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 index 9f4897184..00c3af202 100644 --- a/components/manifests/overlays/kind/keycloak-deployment.yaml +++ b/components/manifests/overlays/kind/keycloak-deployment.yaml @@ -24,7 +24,7 @@ spec: - --import-realm env: - name: KC_HOSTNAME - value: "http://localhost:11646/sso" + value: "http://localhost/sso" - name: KC_HOSTNAME_BACKCHANNEL_DYNAMIC value: "true" - name: KC_PROXY_HEADERS diff --git a/components/manifests/overlays/kind/sso-credentials.yaml b/components/manifests/overlays/kind/sso-credentials.yaml index 5b6cc20bc..7553d230b 100644 --- a/components/manifests/overlays/kind/sso-credentials.yaml +++ b/components/manifests/overlays/kind/sso-credentials.yaml @@ -7,7 +7,7 @@ metadata: type: Opaque stringData: SSO_ISSUER_URL: "http://keycloak-service:8080/realms/ambient-code" - SSO_FRONTEND_ISSUER_URL: "http://localhost:11646/sso/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" From 096bd4947fb06ea0bbbe4fe2b987638257f1467e Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 15 May 2026 12:06:38 -0400 Subject: [PATCH 38/39] fix: make kind-sso-toggle set SSO URLs with correct local dev port The Kind overlay defaults SSO URLs to http://localhost (port 80) for CI, but local dev uses dynamic port-forward ports (11000+offset). kind-sso-toggle now patches SSO_REDIRECT_URI, SSO_PUBLIC_ISSUER_URL, and KC_HOSTNAME with the correct KIND_FWD_FRONTEND_PORT when enabling SSO. This makes SSO work seamlessly in local dev without manual URL configuration. Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index d7b7bc33f..8175ee7fa 100755 --- a/Makefile +++ b/Makefile @@ -1078,15 +1078,25 @@ kind-sso-toggle: check-kubectl ## Toggle SSO auth on/off in Kind (affects both f 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)..."; \ - kubectl set env deployment/frontend -n $(NAMESPACE) SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true; \ + 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. Frontend will redirect to Keycloak login."; \ + echo "$(COLOR_GREEN)✓$(COLOR_RESET) SSO enabled at $$SSO_HOST"; \ fi - @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Waiting for frontend rollout..." + @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Waiting for rollouts..." @kubectl rollout status deployment/frontend -n $(NAMESPACE) --timeout=60s >/dev/null 2>&1 + @kubectl rollout status deployment/keycloak -n $(NAMESPACE) --timeout=120s >/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 From 622d993a74273d4b7f93e7877346cffdd6f27e15 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 15 May 2026 12:10:29 -0400 Subject: [PATCH 39/39] fix: restart backend after Keycloak in kind-sso-toggle The backend's InitJWTValidator needs Keycloak for OIDC discovery. When toggling SSO on, KC_HOSTNAME changes cause Keycloak to restart. Wait for Keycloak to be ready, then restart the backend so OIDC discovery succeeds. Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8175ee7fa..f7ca4c1a4 100755 --- a/Makefile +++ b/Makefile @@ -1094,8 +1094,10 @@ kind-sso-toggle: check-kubectl ## Toggle SSO auth on/off in Kind (affects both f echo "$(COLOR_GREEN)✓$(COLOR_RESET) SSO enabled at $$SSO_HOST"; \ fi @echo "$(COLOR_BLUE)▶$(COLOR_RESET) Waiting for rollouts..." - @kubectl rollout status deployment/frontend -n $(NAMESPACE) --timeout=60s >/dev/null 2>&1 @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"