diff --git a/docs/OIDC_PROXY_AUTH_ARCHITECTURE.md b/docs/OIDC_PROXY_AUTH_ARCHITECTURE.md new file mode 100644 index 000000000..01b559232 --- /dev/null +++ b/docs/OIDC_PROXY_AUTH_ARCHITECTURE.md @@ -0,0 +1,168 @@ +# OIDC Proxy Authentication Architecture + +This document describes the authentication architecture introduced in the `feature/oidc-proxy-auth` branch. + +## Overview + +This PR adds OIDC proxy-based authentication to Kagent, allowing integration with enterprise identity providers via oauth2-proxy. The architecture follows a "trust the proxy" model where an upstream reverse proxy (oauth2-proxy) handles OIDC authentication and injects JWT tokens into requests. + +## Authentication Flow + +```mermaid +sequenceDiagram + participant User as User Browser + participant Proxy as oauth2-proxy + participant IDP as OIDC Provider + participant UI as Next.js UI + participant Controller as Go Controller + + User->>Proxy: Access any route + alt No valid session + Proxy->>IDP: OIDC Authorization Request + IDP->>User: Login prompt + User->>IDP: Credentials + IDP->>Proxy: Authorization code + Proxy->>IDP: Exchange for tokens + Proxy->>User: Set session cookie + redirect + end + + User->>Proxy: Request with session cookie + Proxy->>Proxy: Validate session + Proxy->>UI: Request + Authorization: Bearer + UI->>UI: AuthContext decodes JWT + UI->>Controller: API calls with JWT forwarded + Controller->>Controller: ProxyAuthenticator extracts claims + Controller->>UI: Response +``` + +## Component Architecture + +```mermaid +flowchart TB + subgraph External["External Layer"] + Browser["User Browser"] + IDP["OIDC Identity Provider
(Cognito, Okta, etc.)"] + end + + subgraph Proxy["Authentication Proxy Layer"] + OAuth2Proxy["oauth2-proxy
- Session management
- Token refresh
- JWT injection"] + end + + subgraph UI["UI Layer (Next.js)"] + LoginPage["/login Page
SSO redirect button"] + AuthContext["AuthContext Provider
- User state management
- Loading/error states"] + AuthActions["Server Actions
getCurrentUser()"] + JWTLib["JWT Library
- Decode tokens
- Check expiry"] + AuthLib["Auth Library
- Header forwarding"] + end + + subgraph Backend["Backend Layer (Go)"] + ProxyAuth["ProxyAuthenticator
- Raw JWT claims passthrough
- Service account fallback"] + HTTPServer["HTTP Server
API endpoints"] + end + + Browser -->|"1. Unauthenticated"| OAuth2Proxy + OAuth2Proxy -->|"2. OIDC flow"| IDP + IDP -->|"3. Tokens"| OAuth2Proxy + OAuth2Proxy -->|"4. JWT in header"| UI + + AuthContext --> AuthActions + AuthActions --> JWTLib + + AuthLib -->|"5. Forward JWT"| HTTPServer + HTTPServer --> ProxyAuth + ProxyAuth -->|"6. Extract Principal + raw claims"| HTTPServer +``` + +## Key Components + +### Frontend (UI) + +| Component | File | Purpose | +|-----------|------|---------| +| **Login Page** | `ui/src/app/login/page.tsx` | Branded login UI with SSO redirect button | +| **AuthContext** | `ui/src/contexts/AuthContext.tsx` | React context managing user state, loading, and error states | +| **Auth Actions** | `ui/src/app/actions/auth.ts` | Server action to get current user from JWT (returns raw claims) | +| **JWT Library** | `ui/src/lib/jwt.ts` | Decode JWT tokens and check expiry | +| **Auth Library** | `ui/src/lib/auth.ts` | Extract and forward auth headers to backend | + +### Backend (Go) + +| Component | File | Purpose | +|-----------|------|---------| +| **ProxyAuthenticator** | `go/internal/httpserver/auth/proxy_authn.go` | Extract user identity from JWT Bearer tokens, pass through raw claims | +| **CurrentUserHandler** | `go/internal/httpserver/handlers/current_user.go` | Returns raw JWT claims (or `{"sub": userId}` for non-JWT auth) | + +## Authentication Modes + +The system supports two authentication modes, configured via the `auth-mode` flag / `AUTH_MODE` environment variable: + +1. **`trusted-proxy`** (new): Trust oauth2-proxy to handle authentication, extract identity from JWT +2. **`unsecure`** (existing): No authentication, for development/testing + +## Configuration + +Only two configuration options are needed: + +| Flag | Env Var | Default | Description | +|------|---------|---------|-------------| +| `--auth-mode` | `AUTH_MODE` | `unsecure` | Authentication mode: `unsecure` or `trusted-proxy` | +| `--auth-user-id-claim` | `AUTH_USER_ID_CLAIM` | `sub` | JWT claim name for user identity | + +### Raw Claims Passthrough + +Instead of mapping specific JWT claims to fixed fields, the backend passes through all raw JWT claims. The `/api/me` endpoint returns the full JWT payload as-is, allowing the frontend to display whatever claims are available (name, email, groups, etc.) without backend configuration. + +This approach: +- **Eliminates claim mapping configuration** — no need for `AUTH_JWT_CLAIM_EMAIL`, `AUTH_JWT_CLAIM_NAME`, etc. +- **Works with any OIDC provider** — Cognito, Okta, Azure AD, etc. all use different claim names +- **Frontend adapts automatically** — the UI tries common claim names (`name`, `preferred_username`, `email`) for display + +## Authentication Boundary + +Authentication redirects are handled entirely by oauth2-proxy at the ingress layer. The UI and backend trust that any request reaching them has already been authenticated. + +```mermaid +flowchart TD + A[Request arrives] --> B{oauth2-proxy:
Valid session?} + B -->|No| C[Redirect to OIDC provider] + B -->|Yes| D[Inject JWT header] + D --> E[Forward to UI/Backend] + E --> F{AuthContext:
JWT valid?} + F -->|Yes| G[Set user state] + F -->|No| H[Set error state] + + style C fill:#f96,stroke:#333 + style H fill:#ff9,stroke:#333 +``` + +**Design rationale**: The UI does not redirect on auth failure. If `getCurrentUser()` fails, it indicates a misconfiguration (oauth2-proxy should have intercepted the request) rather than a normal session expiry. The error state surfaces this for debugging rather than masking it with a redirect loop. + +## Service Account Fallback + +For internal agent-to-controller communication, the `ProxyAuthenticator` supports a fallback mechanism: + +```mermaid +flowchart TD + A[Incoming Request] --> B{Has Bearer token?} + B -->|Yes| C[Parse JWT claims] + C --> D[Return Principal with raw claims] + B -->|No| E{Has X-Agent-Name + user_id?} + E -->|Yes| F[Return Principal from user_id] + E -->|No| G[Return ErrUnauthenticated] +``` + +This allows agents running inside the cluster to authenticate without a full JWT. + +## Deployment Configuration + +oauth2-proxy is deployed as an optional Helm subchart dependency, configured in: +- `helm/kagent/Chart.yaml` - subchart dependency +- `helm/kagent/values.yaml` - oauth2-proxy configuration + +## Security Considerations + +1. **JWT validation is delegated to oauth2-proxy** — The backend does not re-validate JWT signatures, trusting that oauth2-proxy has already done so +2. **Tokens are forwarded upstream** — The original Authorization header is preserved for backend API calls +3. **Session cookies are httpOnly** — Managed by oauth2-proxy, not accessible to JavaScript +4. **Network policies** — NetworkPolicies to restrict direct access to UI/Controller (bypassing oauth2-proxy) are planned for a follow-up PR diff --git a/go/cmd/controller/auth_mode_test.go b/go/cmd/controller/auth_mode_test.go new file mode 100644 index 000000000..787876929 --- /dev/null +++ b/go/cmd/controller/auth_mode_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "testing" + + authimpl "github.com/kagent-dev/kagent/go/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/pkg/auth" +) + +func TestGetAuthenticator(t *testing.T) { + tests := []struct { + name string + authCfg struct{ Mode, UserIDClaim string } + wantType string + }{ + { + name: "defaults to UnsecureAuthenticator", + authCfg: struct{ Mode, UserIDClaim string }{"", ""}, + wantType: "*auth.UnsecureAuthenticator", + }, + { + name: "unsecure mode uses UnsecureAuthenticator", + authCfg: struct{ Mode, UserIDClaim string }{"unsecure", ""}, + wantType: "*auth.UnsecureAuthenticator", + }, + { + name: "trusted-proxy mode uses ProxyAuthenticator", + authCfg: struct{ Mode, UserIDClaim string }{"trusted-proxy", ""}, + wantType: "*auth.ProxyAuthenticator", + }, + { + name: "trusted-proxy mode with custom claim", + authCfg: struct{ Mode, UserIDClaim string }{"trusted-proxy", "user_id"}, + wantType: "*auth.ProxyAuthenticator", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authenticator := getAuthenticator(tt.authCfg) + gotType := getTypeName(authenticator) + if gotType != tt.wantType { + t.Errorf("getAuthenticator() = %s, want %s", gotType, tt.wantType) + } + }) + } +} + +func getTypeName(v auth.AuthProvider) string { + switch v.(type) { + case *authimpl.UnsecureAuthenticator: + return "*auth.UnsecureAuthenticator" + case *authimpl.ProxyAuthenticator: + return "*auth.ProxyAuthenticator" + default: + return "unknown" + } +} diff --git a/go/cmd/controller/main.go b/go/cmd/controller/main.go index 2049e6962..de3d663b1 100644 --- a/go/cmd/controller/main.go +++ b/go/cmd/controller/main.go @@ -19,6 +19,7 @@ package main import ( "github.com/kagent-dev/kagent/go/internal/httpserver/auth" "github.com/kagent-dev/kagent/go/pkg/app" + pkgauth "github.com/kagent-dev/kagent/go/pkg/auth" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -28,8 +29,8 @@ import ( //nolint:gocyclo func main() { authorizer := &auth.NoopAuthorizer{} - authenticator := &auth.UnsecureAuthenticator{} app.Start(func(bootstrap app.BootstrapConfig) (*app.ExtensionConfig, error) { + authenticator := getAuthenticator(bootstrap.Config.Auth) return &app.ExtensionConfig{ Authenticator: authenticator, Authorizer: authorizer, @@ -38,3 +39,12 @@ func main() { }, nil }) } + +func getAuthenticator(authCfg struct{ Mode, UserIDClaim string }) pkgauth.AuthProvider { + switch authCfg.Mode { + case "trusted-proxy": + return auth.NewProxyAuthenticator(authCfg.UserIDClaim) + default: + return &auth.UnsecureAuthenticator{} + } +} diff --git a/go/internal/httpserver/auth/proxy_authn.go b/go/internal/httpserver/auth/proxy_authn.go new file mode 100644 index 000000000..42d4a62f6 --- /dev/null +++ b/go/internal/httpserver/auth/proxy_authn.go @@ -0,0 +1,113 @@ +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + + "github.com/kagent-dev/kagent/go/pkg/auth" +) + +var ErrUnauthenticated = errors.New("unauthenticated: missing or invalid Authorization header") + +type ProxyAuthenticator struct { + userIDClaim string +} + +func NewProxyAuthenticator(userIDClaim string) *ProxyAuthenticator { + if userIDClaim == "" { + userIDClaim = "sub" + } + return &ProxyAuthenticator{userIDClaim: userIDClaim} +} + +func (a *ProxyAuthenticator) Authenticate(ctx context.Context, reqHeaders http.Header, query url.Values) (auth.Session, error) { + authHeader := reqHeaders.Get("Authorization") + + // Always read agent identity from X-Agent-Name header (used by agents calling back) + agentID := reqHeaders.Get("X-Agent-Name") + + // If we have a Bearer token, parse JWT + if tokenString, ok := strings.CutPrefix(authHeader, "Bearer "); ok { + // Parse JWT without validation (oauth2-proxy or k8s service account already validated) + rawClaims, err := parseJWTPayload(tokenString) + if err != nil { + return nil, ErrUnauthenticated + } + + userID, _ := rawClaims[a.userIDClaim].(string) + if userID == "" && a.userIDClaim != "sub" { + userID, _ = rawClaims["sub"].(string) + } + if userID == "" { + return nil, ErrUnauthenticated + } + + return &SimpleSession{ + P: auth.Principal{ + User: auth.User{ID: userID}, + Agent: auth.Agent{ID: agentID}, + Claims: rawClaims, + }, + authHeader: authHeader, + }, nil + } + + // Fall back to service account auth for internal agent-to-controller calls. + // Requires X-Agent-Name to identify the calling agent. + if agentID == "" { + return nil, ErrUnauthenticated + } + + // Agents authenticate via user_id query param or X-User-Id header + userID := query.Get("user_id") + if userID == "" { + userID = reqHeaders.Get("X-User-Id") + } + if userID == "" { + return nil, ErrUnauthenticated + } + + return &SimpleSession{ + P: auth.Principal{ + User: auth.User{ + ID: userID, + }, + Agent: auth.Agent{ + ID: agentID, + }, + }, + authHeader: authHeader, + }, nil +} + +func (a *ProxyAuthenticator) UpstreamAuth(r *http.Request, session auth.Session, upstreamPrincipal auth.Principal) error { + if simpleSession, ok := session.(*SimpleSession); ok && simpleSession.authHeader != "" { + r.Header.Set("Authorization", simpleSession.authHeader) + } + return nil +} + +// parseJWTPayload decodes JWT payload without signature verification +func parseJWTPayload(tokenString string) (map[string]any, error) { + parts := strings.Split(tokenString, ".") + if len(parts) != 3 { + return nil, errors.New("invalid JWT format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } + + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, err + } + + return claims, nil +} diff --git a/go/internal/httpserver/auth/proxy_authn_test.go b/go/internal/httpserver/auth/proxy_authn_test.go new file mode 100644 index 000000000..fd5231ee0 --- /dev/null +++ b/go/internal/httpserver/auth/proxy_authn_test.go @@ -0,0 +1,342 @@ +package auth_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "testing" + + authimpl "github.com/kagent-dev/kagent/go/internal/httpserver/auth" +) + +// createTestJWT creates a minimal JWT token with the given claims +func createTestJWT(claims map[string]any) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payload, _ := json.Marshal(claims) + payloadB64 := base64.RawURLEncoding.EncodeToString(payload) + signature := base64.RawURLEncoding.EncodeToString([]byte("fake-signature")) + return header + "." + payloadB64 + "." + signature +} + +func TestProxyAuthenticator_Authenticate(t *testing.T) { + tests := []struct { + name string + claims map[string]any + userIDClaim string + wantUserID string + wantClaims map[string]any + wantErr bool + noToken bool + invalidToken bool + }{ + { + name: "extracts standard claims and passes through raw claims", + claims: map[string]any{ + "sub": "user123", + "email": "user@example.com", + "name": "Test User", + "groups": []any{"admin", "developers"}, + }, + wantUserID: "user123", + wantClaims: map[string]any{ + "sub": "user123", + "email": "user@example.com", + "name": "Test User", + }, + wantErr: false, + }, + { + name: "uses custom user ID claim", + claims: map[string]any{ + "user_id": "custom-user-123", + "email": "custom@example.com", + "sub": "fallback-sub", + }, + userIDClaim: "user_id", + wantUserID: "custom-user-123", + wantClaims: map[string]any{ + "user_id": "custom-user-123", + "email": "custom@example.com", + "sub": "fallback-sub", + }, + wantErr: false, + }, + { + name: "falls back to sub when custom claim is missing", + claims: map[string]any{ + "sub": "fallback-user", + "email": "user@example.com", + }, + userIDClaim: "user_id", + wantUserID: "fallback-user", + wantErr: false, + }, + { + name: "returns error when Authorization header missing", + noToken: true, + wantErr: true, + }, + { + name: "returns error for invalid JWT format", + invalidToken: true, + wantErr: true, + }, + { + name: "handles minimal claims", + claims: map[string]any{ + "sub": "user123", + }, + wantUserID: "user123", + wantClaims: map[string]any{ + "sub": "user123", + }, + wantErr: false, + }, + { + name: "returns error when JWT has empty sub claim", + claims: map[string]any{ + "email": "user@example.com", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth := authimpl.NewProxyAuthenticator(tt.userIDClaim) + + headers := http.Header{} + if !tt.noToken { + if tt.invalidToken { + headers.Set("Authorization", "Bearer invalid-token") + } else { + token := createTestJWT(tt.claims) + headers.Set("Authorization", "Bearer "+token) + } + } + + session, err := auth.Authenticate(context.Background(), headers, url.Values{}) + + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + principal := session.Principal() + if principal.User.ID != tt.wantUserID { + t.Errorf("User.ID = %q, want %q", principal.User.ID, tt.wantUserID) + } + + // Verify raw claims are passed through + if tt.wantClaims != nil { + if principal.Claims == nil { + t.Fatal("expected Claims to be non-nil") + } + for k, wantV := range tt.wantClaims { + gotV, ok := principal.Claims[k] + if !ok { + t.Errorf("Claims[%q] missing", k) + continue + } + // Compare as strings for simple values + if wantStr, ok := wantV.(string); ok { + if gotStr, ok := gotV.(string); !ok || gotStr != wantStr { + t.Errorf("Claims[%q] = %v, want %q", k, gotV, wantStr) + } + } + } + } + }) + } +} + +func TestProxyAuthenticator_JWTWithAgentHeader(t *testing.T) { + tests := []struct { + name string + claims map[string]any + agentName string + wantUserID string + wantAgentID string + }{ + { + name: "extracts agent identity from header when JWT is present", + claims: map[string]any{ + "sub": "system:serviceaccount:kagent:kebab-agent", + "iss": "https://kubernetes.default.svc.cluster.local", + "aud": []any{"kagent"}, + }, + agentName: "kagent__NS__kebab_agent", + wantUserID: "system:serviceaccount:kagent:kebab-agent", + wantAgentID: "kagent__NS__kebab_agent", + }, + { + name: "works with OIDC JWT and agent header", + claims: map[string]any{ + "sub": "user123", + "email": "user@example.com", + }, + agentName: "kagent__NS__my_agent", + wantUserID: "user123", + wantAgentID: "kagent__NS__my_agent", + }, + { + name: "handles JWT without agent header", + claims: map[string]any{ + "sub": "user123", + }, + agentName: "", + wantUserID: "user123", + wantAgentID: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth := authimpl.NewProxyAuthenticator("") + + headers := http.Header{} + token := createTestJWT(tt.claims) + headers.Set("Authorization", "Bearer "+token) + if tt.agentName != "" { + headers.Set("X-Agent-Name", tt.agentName) + } + + session, err := auth.Authenticate(context.Background(), headers, url.Values{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + principal := session.Principal() + if principal.User.ID != tt.wantUserID { + t.Errorf("User.ID = %q, want %q", principal.User.ID, tt.wantUserID) + } + if principal.Agent.ID != tt.wantAgentID { + t.Errorf("Agent.ID = %q, want %q", principal.Agent.ID, tt.wantAgentID) + } + }) + } +} + +func TestProxyAuthenticator_ServiceAccountFallback(t *testing.T) { + tests := []struct { + name string + headers map[string]string + queryParams map[string]string + wantUserID string + wantAgentID string + wantErr bool + }{ + { + name: "authenticates via user_id query param with agent name", + queryParams: map[string]string{ + "user_id": "system:serviceaccount:kagent:kebab-agent", + }, + headers: map[string]string{ + "X-Agent-Name": "kagent/kebab-agent", + }, + wantUserID: "system:serviceaccount:kagent:kebab-agent", + wantAgentID: "kagent/kebab-agent", + wantErr: false, + }, + { + name: "authenticates via X-User-Id header with agent name", + headers: map[string]string{ + "X-User-Id": "system:serviceaccount:kagent:test-agent", + "X-Agent-Name": "kagent/test-agent", + }, + wantUserID: "system:serviceaccount:kagent:test-agent", + wantAgentID: "kagent/test-agent", + wantErr: false, + }, + { + name: "returns error when no auth method available", + wantErr: true, + }, + { + name: "returns error when no X-Agent-Name header for fallback", + queryParams: map[string]string{ + "user_id": "system:serviceaccount:kagent:kebab-agent", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth := authimpl.NewProxyAuthenticator("") + + headers := http.Header{} + for k, v := range tt.headers { + headers.Set(k, v) + } + + query := url.Values{} + for k, v := range tt.queryParams { + query.Set(k, v) + } + + session, err := auth.Authenticate(context.Background(), headers, query) + + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + principal := session.Principal() + if principal.User.ID != tt.wantUserID { + t.Errorf("User.ID = %q, want %q", principal.User.ID, tt.wantUserID) + } + if principal.Agent.ID != tt.wantAgentID { + t.Errorf("Agent.ID = %q, want %q", principal.Agent.ID, tt.wantAgentID) + } + }) + } +} + +func TestProxyAuthenticator_UpstreamAuth(t *testing.T) { + auth := authimpl.NewProxyAuthenticator("") + + claims := map[string]any{ + "sub": "user123", + "email": "user@example.com", + } + token := createTestJWT(claims) + authHeader := "Bearer " + token + + headers := http.Header{} + headers.Set("Authorization", authHeader) + + session, err := auth.Authenticate(context.Background(), headers, url.Values{}) + if err != nil { + t.Fatalf("failed to authenticate: %v", err) + } + + // Create a new request to test UpstreamAuth + req, _ := http.NewRequest("GET", "http://example.com", nil) + + err = auth.UpstreamAuth(req, session, session.Principal()) + if err != nil { + t.Errorf("UpstreamAuth returned error: %v", err) + } + + // Verify the Authorization header was forwarded + if got := req.Header.Get("Authorization"); got != authHeader { + t.Errorf("Authorization header = %q, want %q", got, authHeader) + } +} diff --git a/go/internal/httpserver/handlers/current_user.go b/go/internal/httpserver/handlers/current_user.go new file mode 100644 index 000000000..076bf7691 --- /dev/null +++ b/go/internal/httpserver/handlers/current_user.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "net/http" + + "github.com/kagent-dev/kagent/go/pkg/auth" +) + +type CurrentUserHandler struct{} + +func NewCurrentUserHandler() *CurrentUserHandler { + return &CurrentUserHandler{} +} + +func (h *CurrentUserHandler) HandleGetCurrentUser(w http.ResponseWriter, r *http.Request) { + session, ok := auth.AuthSessionFrom(r.Context()) + if !ok || session == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + principal := session.Principal() + if principal.Claims != nil { + RespondWithJSON(w, http.StatusOK, principal.Claims) + } else { + RespondWithJSON(w, http.StatusOK, map[string]any{ + "sub": principal.User.ID, + }) + } +} diff --git a/go/internal/httpserver/handlers/current_user_test.go b/go/internal/httpserver/handlers/current_user_test.go new file mode 100644 index 000000000..aeaf7426f --- /dev/null +++ b/go/internal/httpserver/handlers/current_user_test.go @@ -0,0 +1,105 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kagent-dev/kagent/go/internal/httpserver/handlers" + "github.com/kagent-dev/kagent/go/pkg/auth" +) + +type mockSession struct { + principal auth.Principal +} + +func (m *mockSession) Principal() auth.Principal { + return m.principal +} + +func TestHandleGetCurrentUser(t *testing.T) { + tests := []struct { + name string + session auth.Session + wantStatusCode int + wantResponse map[string]any + }{ + { + name: "returns raw claims from JWT session", + session: &mockSession{ + principal: auth.Principal{ + User: auth.User{ID: "user123"}, + Claims: map[string]any{ + "sub": "user123", + "email": "user@example.com", + "name": "Test User", + "groups": []any{"admin", "developers"}, + }, + }, + }, + wantStatusCode: http.StatusOK, + wantResponse: map[string]any{ + "sub": "user123", + "email": "user@example.com", + "name": "Test User", + }, + }, + { + name: "returns sub-only map for non-JWT session", + session: &mockSession{ + principal: auth.Principal{ + User: auth.User{ID: "admin@kagent.dev"}, + }, + }, + wantStatusCode: http.StatusOK, + wantResponse: map[string]any{ + "sub": "admin@kagent.dev", + }, + }, + { + name: "returns 401 when no session", + session: nil, + wantStatusCode: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := handlers.NewCurrentUserHandler() + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + if tt.session != nil { + ctx := auth.AuthSessionTo(req.Context(), tt.session) + req = req.WithContext(ctx) + } + + rr := httptest.NewRecorder() + handler.HandleGetCurrentUser(rr, req) + + if rr.Code != tt.wantStatusCode { + t.Errorf("status code = %d, want %d", rr.Code, tt.wantStatusCode) + } + + if tt.wantStatusCode == http.StatusOK { + var response map[string]any + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + for k, wantV := range tt.wantResponse { + gotV, ok := response[k] + if !ok { + t.Errorf("response missing key %q", k) + continue + } + if wantStr, ok := wantV.(string); ok { + if gotStr, ok := gotV.(string); !ok || gotStr != wantStr { + t.Errorf("response[%q] = %v, want %q", k, gotV, wantStr) + } + } + } + } + }) + } +} diff --git a/go/internal/httpserver/server.go b/go/internal/httpserver/server.go index 2011c82a7..15dd89507 100644 --- a/go/internal/httpserver/server.go +++ b/go/internal/httpserver/server.go @@ -25,6 +25,7 @@ const ( // API Path constants APIPathHealth = "/health" APIPathVersion = "/version" + APIPathMe = "/api/me" APIPathModelConfig = "/api/modelconfigs" APIPathRuns = "/api/runs" APIPathSessions = "/api/sessions" @@ -195,6 +196,11 @@ func (s *HTTPServer) setupRoutes() { handlers.RespondWithJSON(erw, http.StatusOK, versionResponse) })).Methods(http.MethodGet) + // Current user + s.router.HandleFunc(APIPathMe, adaptHandler(func(erw handlers.ErrorResponseWriter, r *http.Request) { + s.handlers.CurrentUser.HandleGetCurrentUser(erw, r) + })).Methods(http.MethodGet) + // Model configs s.router.HandleFunc(APIPathModelConfig, adaptHandler(s.handlers.ModelConfig.HandleListModelConfigs)).Methods(http.MethodGet) s.router.HandleFunc(APIPathModelConfig+"/{namespace}/{name}", adaptHandler(s.handlers.ModelConfig.HandleGetModelConfig)).Methods(http.MethodGet) diff --git a/go/pkg/app/app.go b/go/pkg/app/app.go index 56af170db..8e866f53a 100644 --- a/go/pkg/app/app.go +++ b/go/pkg/app/app.go @@ -114,6 +114,10 @@ type Config struct { Proxy struct { URL string } + Auth struct { + Mode string + UserIDClaim string + } LeaderElection bool ProbeAddr string SecureMetrics bool @@ -165,10 +169,13 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { commandLine.Var(&cfg.Streaming.MaxBufSize, "streaming-max-buf-size", "The maximum size of the streaming buffer.") commandLine.Var(&cfg.Streaming.InitialBufSize, "streaming-initial-buf-size", "The initial size of the streaming buffer.") - commandLine.DurationVar(&cfg.Streaming.Timeout, "streaming-timeout", 600*time.Second, "The timeout for the streaming connection.") + commandLine.DurationVar(&cfg.Streaming.Timeout, "streaming-timeout", 60*time.Second, "The timeout for the streaming connection.") commandLine.StringVar(&cfg.Proxy.URL, "proxy-url", "", "Proxy URL for internally-built k8s URLs (e.g., http://proxy.kagent.svc.cluster.local:8080)") + commandLine.StringVar(&cfg.Auth.Mode, "auth-mode", "unsecure", "Authentication mode: unsecure or proxy") + commandLine.StringVar(&cfg.Auth.UserIDClaim, "auth-user-id-claim", "sub", "JWT claim name for user identity") + commandLine.StringVar(&agent_translator.DefaultImageConfig.Registry, "image-registry", agent_translator.DefaultImageConfig.Registry, "The registry to use for the image.") commandLine.StringVar(&agent_translator.DefaultImageConfig.Tag, "image-tag", agent_translator.DefaultImageConfig.Tag, "The tag to use for the image.") commandLine.StringVar(&agent_translator.DefaultImageConfig.PullPolicy, "image-pull-policy", agent_translator.DefaultImageConfig.PullPolicy, "The pull policy to use for the image.") @@ -203,6 +210,7 @@ type BootstrapConfig struct { Manager manager.Manager Router *mux.Router DbClient dbpkg.Client + Config *Config } type CtrlManagerConfigFunc func(manager.Manager) error @@ -382,6 +390,7 @@ func Start(getExtensionConfig GetExtensionConfig) { Manager: mgr, Router: router, DbClient: dbClient, + Config: &cfg, }) if err != nil { setupLog.Error(err, "unable to get start config") diff --git a/go/pkg/auth/auth.go b/go/pkg/auth/auth.go index 469d8619d..6a6b793fd 100644 --- a/go/pkg/auth/auth.go +++ b/go/pkg/auth/auth.go @@ -21,17 +21,18 @@ type Resource struct { } type User struct { - ID string - Roles []string + ID string } + type Agent struct { ID string } // Authn type Principal struct { - User User - Agent Agent + User User + Agent Agent + Claims map[string]any // Raw JWT claims (nil for non-JWT auth) } type Session interface { @@ -75,6 +76,11 @@ func AuthSessionTo(ctx context.Context, session Session) context.Context { func AuthnMiddleware(authn AuthProvider) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip authentication for health and version endpoints (used by probes) + if r.URL.Path == "/health" || r.URL.Path == "/version" { + next.ServeHTTP(w, r) + return + } session, err := authn.Authenticate(r.Context(), r.Header, r.URL.Query()) if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) diff --git a/go/pkg/auth/auth_test.go b/go/pkg/auth/auth_test.go new file mode 100644 index 000000000..835e16c25 --- /dev/null +++ b/go/pkg/auth/auth_test.go @@ -0,0 +1,27 @@ +package auth + +import ( + "testing" +) + +func TestPrincipalHasRequiredFields(t *testing.T) { + p := Principal{ + User: User{ID: "user123"}, + Agent: Agent{ID: "agent1"}, + Claims: map[string]any{ + "sub": "user123", + "email": "user@example.com", + "name": "Test User", + }, + } + + if p.User.ID != "user123" { + t.Errorf("expected User.ID 'user123', got '%s'", p.User.ID) + } + if p.Claims["email"] != "user@example.com" { + t.Errorf("expected Claims[email] 'user@example.com', got '%v'", p.Claims["email"]) + } + if p.Claims["name"] != "Test User" { + t.Errorf("expected Claims[name] 'Test User', got '%v'", p.Claims["name"]) + } +} diff --git a/go/test/e2e/auth_api_test.go b/go/test/e2e/auth_api_test.go new file mode 100644 index 000000000..ea79e4a73 --- /dev/null +++ b/go/test/e2e/auth_api_test.go @@ -0,0 +1,253 @@ +package e2e_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// makeTestJWT builds a minimal unsigned JWT (alg:none) with the given claims. +// This is sufficient for trusted-proxy mode testing where the oauth2-proxy has already +// validated the token and the backend only parses claims without verification. +func makeTestJWT(claims map[string]any) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + payload, _ := json.Marshal(claims) + payloadB64 := base64.RawURLEncoding.EncodeToString(payload) + return header + "." + payloadB64 + "." +} + +// kagentURL returns the base URL for kagent API. +// Configurable via KAGENT_URL env var. +func kagentURL() string { + if url := os.Getenv("KAGENT_URL"); url != "" { + return url + } + return "http://localhost:8083" +} + +// detectAuthMode probes /api/me to determine if the deployment is in trusted-proxy or unsecure mode. +// Sends a JWT Bearer token; in trusted-proxy mode the backend parses the JWT and returns the sub claim. +// In unsecure mode the backend ignores the Bearer token and returns the default user. +// Returns "trusted-proxy" if trusted-proxy mode, "unsecure" otherwise. +func detectAuthMode(t *testing.T) string { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + token := makeTestJWT(map[string]any{"sub": "probe-user"}) + req, err := http.NewRequestWithContext(ctx, "GET", kagentURL()+"/api/me", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + var userResp map[string]any + err = json.NewDecoder(resp.Body).Decode(&userResp) + require.NoError(t, err) + + if sub, _ := userResp["sub"].(string); sub == "probe-user" { + return "trusted-proxy" + } + } + return "unsecure" +} + +// makeAuthRequest makes a GET request to /api/me with optional headers and query params. +func makeAuthRequest(t *testing.T, headers map[string]string, queryParams map[string]string) (*http.Response, []byte) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + reqURL := kagentURL() + "/api/me" + if len(queryParams) > 0 { + var sb strings.Builder + sb.WriteString(reqURL) + sb.WriteString("?") + first := true + for k, v := range queryParams { + if !first { + sb.WriteString("&") + } + sb.WriteString(k) + sb.WriteString("=") + sb.WriteString(v) + first = false + } + reqURL = sb.String() + } + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + require.NoError(t, err) + + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + resp.Body.Close() + + return resp, body +} + +// parseUserResponse parses a raw claims map from JSON body. +func parseUserResponse(t *testing.T, body []byte) map[string]any { + t.Helper() + var userResp map[string]any + err := json.Unmarshal(body, &userResp) + require.NoError(t, err) + return userResp +} + +func TestE2EAuthUnsecureMode(t *testing.T) { + // Skip if deployment is in proxy mode + if detectAuthMode(t) == "trusted-proxy" { + t.Skip("Skipping unsecure mode tests - deployment is in trusted-proxy mode") + } + + t.Run("default_user", func(t *testing.T) { + // GET /api/me with no auth headers should return default user + resp, body := makeAuthRequest(t, nil, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + + userResp := parseUserResponse(t, body) + require.Equal(t, "admin@kagent.dev", userResp["sub"]) + }) + + t.Run("x_user_id_header", func(t *testing.T) { + // GET /api/me with X-User-Id header should return that user + resp, body := makeAuthRequest(t, map[string]string{ + "X-User-Id": "alice@example.com", + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + + userResp := parseUserResponse(t, body) + require.Equal(t, "alice@example.com", userResp["sub"]) + }) + + t.Run("user_id_query_param", func(t *testing.T) { + // GET /api/me?user_id=bob should return that user + resp, body := makeAuthRequest(t, nil, map[string]string{ + "user_id": "bob@example.com", + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + userResp := parseUserResponse(t, body) + require.Equal(t, "bob@example.com", userResp["sub"]) + }) + + t.Run("header_takes_precedence_over_query", func(t *testing.T) { + // When both header and query param are present, query param takes precedence + // (based on UnsecureAuthenticator implementation which checks query first) + resp, body := makeAuthRequest(t, map[string]string{ + "X-User-Id": "header-user", + }, map[string]string{ + "user_id": "query-user", + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + userResp := parseUserResponse(t, body) + require.Equal(t, "query-user", userResp["sub"]) + }) +} + +func TestE2EAuthProxyMode(t *testing.T) { + // Skip if deployment is not in trusted-proxy mode + if detectAuthMode(t) != "trusted-proxy" { + t.Skip("Skipping trusted-proxy mode tests - deployment is in unsecure mode") + } + + t.Run("full_claims", func(t *testing.T) { + // JWT with all standard claims + token := makeTestJWT(map[string]any{ + "sub": "john", + "email": "john@example.com", + "name": "John Doe", + "groups": []string{"admin", "developers"}, + }) + resp, body := makeAuthRequest(t, map[string]string{ + "Authorization": "Bearer " + token, + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + + userResp := parseUserResponse(t, body) + require.Equal(t, "john", userResp["sub"]) + require.Equal(t, "john@example.com", userResp["email"]) + require.Equal(t, "John Doe", userResp["name"]) + // Groups come through as raw claim + groups, ok := userResp["groups"].([]any) + require.True(t, ok, "groups should be an array") + require.Len(t, groups, 2) + }) + + t.Run("minimal_claims", func(t *testing.T) { + // JWT with only sub claim + token := makeTestJWT(map[string]any{ + "sub": "jane", + }) + resp, body := makeAuthRequest(t, map[string]string{ + "Authorization": "Bearer " + token, + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + + userResp := parseUserResponse(t, body) + require.Equal(t, "jane", userResp["sub"]) + require.Nil(t, userResp["email"]) + require.Nil(t, userResp["name"]) + require.Nil(t, userResp["groups"]) + }) + + t.Run("missing_sub_claim_returns_401", func(t *testing.T) { + // JWT without sub claim should return 401 + token := makeTestJWT(map[string]any{ + "email": "test@example.com", + }) + resp, _ := makeAuthRequest(t, map[string]string{ + "Authorization": "Bearer " + token, + }, nil) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("no_bearer_token_returns_401", func(t *testing.T) { + // No Authorization header and no agent identity should return 401 + resp, _ := makeAuthRequest(t, nil, nil) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("agent_fallback_with_user_id", func(t *testing.T) { + // Agent callback: X-Agent-Name + user_id query param (no Bearer token) + resp, body := makeAuthRequest(t, map[string]string{ + "X-Agent-Name": "kagent/test-agent", + }, map[string]string{ + "user_id": "owner@example.com", + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + userResp := parseUserResponse(t, body) + require.Equal(t, "owner@example.com", userResp["sub"]) + }) + + t.Run("fallback_without_agent_name_returns_401", func(t *testing.T) { + // user_id query param without X-Agent-Name should return 401 + resp, _ := makeAuthRequest(t, nil, map[string]string{ + "user_id": "owner@example.com", + }) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} diff --git a/helm/kagent/Chart-template.yaml b/helm/kagent/Chart-template.yaml index 704817917..7344f9e05 100644 --- a/helm/kagent/Chart-template.yaml +++ b/helm/kagent/Chart-template.yaml @@ -60,3 +60,7 @@ dependencies: version: ${VERSION} repository: file://../agents/cilium-debug condition: agents.cilium-debug-agent.enabled + - name: oauth2-proxy + version: "~7.0.0" + repository: "https://oauth2-proxy.github.io/manifests" + condition: oauth2-proxy.enabled diff --git a/helm/kagent/templates/NOTES.txt b/helm/kagent/templates/NOTES.txt index d42bd2a6d..d3583aa99 100644 --- a/helm/kagent/templates/NOTES.txt +++ b/helm/kagent/templates/NOTES.txt @@ -55,7 +55,6 @@ TROUBLESHOOTING: - Check pod status: kubectl -n {{ include "kagent.namespace" . }} get pods - View events: kubectl -n {{ include "kagent.namespace" . }} get events --sort-by='.lastTimestamp' - Controller logs: kubectl -n {{ include "kagent.namespace" . }} logs -l app.kubernetes.io/component=controller -f - DOCUMENTATION: Visit https://kagent.dev for comprehensive documentation and examples. diff --git a/helm/kagent/templates/controller-deployment.yaml b/helm/kagent/templates/controller-deployment.yaml index cdfa8de85..e323e22f5 100644 --- a/helm/kagent/templates/controller-deployment.yaml +++ b/helm/kagent/templates/controller-deployment.yaml @@ -57,6 +57,12 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace + - name: AUTH_MODE + value: {{ .Values.controller.auth.mode | default "unsecure" | quote }} + {{- if .Values.controller.auth.userIdClaim }} + - name: AUTH_USER_ID_CLAIM + value: {{ .Values.controller.auth.userIdClaim | quote }} + {{- end }} {{- with .Values.controller.env }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/helm/kagent/templates/oauth2-proxy-templates.yaml b/helm/kagent/templates/oauth2-proxy-templates.yaml new file mode 100644 index 000000000..0223c9d21 --- /dev/null +++ b/helm/kagent/templates/oauth2-proxy-templates.yaml @@ -0,0 +1,19 @@ +{{- if index .Values "oauth2-proxy" "enabled" }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: kagent-oauth2-proxy-templates + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} +data: + sign_in.html: | + + + + + + + Redirecting to login... + +{{- end }} diff --git a/helm/kagent/templates/ui-deployment.yaml b/helm/kagent/templates/ui-deployment.yaml index 3b009a1e9..196cff6a4 100644 --- a/helm/kagent/templates/ui-deployment.yaml +++ b/helm/kagent/templates/ui-deployment.yaml @@ -43,6 +43,10 @@ spec: env: - name: NEXT_PUBLIC_BACKEND_URL value: "http://{{ include "kagent.fullname" . }}-controller.{{ include "kagent.namespace" . }}.svc.cluster.local:{{ .Values.controller.service.ports.port }}/api" + {{- if .Values.ui.auth }} + - name: SSO_REDIRECT_PATH + value: {{ .Values.ui.auth.ssoRedirectPath | default "/oauth2/start" | quote }} + {{- end }} {{- with .Values.ui.env }} {{- toYaml . | nindent 12 }} {{- end }} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 7ae7c7693..ac71d145e 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -68,6 +68,14 @@ database: controller: replicas: 1 loglevel: "info" + # Authentication mode: "unsecure" (default) or "trusted-proxy" + # - unsecure: uses X-User-Id header/query param or defaults to admin@kagent.dev + # - trusted-proxy: trusts JWT token from Authorization header (set by oauth2-proxy) + auth: + mode: unsecure + # JWT claim for user identity (default: "sub") + # Override only if your OIDC provider uses a different claim + userIdClaim: "" # -- The base URL of the A2A Server endpoint, as advertised to clients. # @default -- `http://-controller..svc.cluster.local:` a2aBaseUrl: "" @@ -155,6 +163,12 @@ ui: ports: port: 8080 targetPort: 8080 + # Authentication redirect configuration + # Change this if using a different auth proxy (e.g., Pomerium, Authelia) + auth: + # Path to redirect users to when they click "Sign in with SSO" on the login page + # Default: /oauth2/start (oauth2-proxy's authentication start endpoint) + ssoRedirectPath: "/oauth2/start" env: {} # Additional configuration key-value pairs for the ui ConfigMap # -- Node taints which will be tolerated for `Pod` [scheduling](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/). @@ -387,6 +401,99 @@ querydoc: openai: apiKey: "" +# ============================================================================== +# OAUTH2-PROXY CONFIGURATION (Optional) +# ============================================================================== +# Enable oauth2-proxy subchart for OIDC authentication +# Requires controller.auth.mode: trusted-proxy + +oauth2-proxy: + enabled: false + + # Disable Redis - use cookie-based sessions + redis: + enabled: false + sessionStorage: + type: cookie + + # Mount custom templates for branded login redirect + # The ConfigMap is created by templates/oauth2-proxy-templates.yaml + extraVolumes: + - name: custom-templates + configMap: + name: kagent-oauth2-proxy-templates + + extraVolumeMounts: + - name: custom-templates + mountPath: /templates + readOnly: true + + # Mount custom CA certificate for TLS verification (if needed) + # Add to extraVolumes: + # - name: custom-ca-cert + # secret: + # secretName: my-ca-cert + # items: + # - key: ca.crt + # path: ca.crt + # Add to extraVolumeMounts: + # - name: custom-ca-cert + # mountPath: /etc/ssl/certs/custom-ca.crt + # subPath: ca.crt + # readOnly: true + # Add to extraEnv: + # - name: SSL_CERT_FILE + # value: /etc/ssl/certs/custom-ca.crt + + config: + # Option 1: Reference existing secret (recommended for production) + # Secret must contain: client-id, client-secret, cookie-secret + existingSecret: "" + + # Option 2: Inline values (for dev/testing only, chart creates secret) + clientID: "" + clientSecret: "" + cookieSecret: "" + + # Cluster-specific OIDC settings - override these per deployment + # These are set as env vars and referenced in args for easy patching + extraEnv: + - name: OIDC_ISSUER_URL + value: "" + - name: OIDC_REDIRECT_URL + value: "" + # Default assumes release name "kagent". Override if using different release name. + - name: UPSTREAM_URL + value: "http://kagent-ui:8080" + + # extraArgs as a map - each key becomes --key=value in container args + # Uses $(VAR_NAME) syntax to reference env vars set above + extraArgs: + provider: oidc + oidc-issuer-url: "$(OIDC_ISSUER_URL)" + redirect-url: "$(OIDC_REDIRECT_URL)" + upstream: "$(UPSTREAM_URL)" + email-domain: "*" + pass-authorization-header: true + set-authorization-header: true + approval-prompt: "auto" + scope: "openid profile email groups" + # Cookie security settings + cookie-secure: true + cookie-samesite: "lax" + # Allow API clients with valid JWT tokens to bypass OIDC flow + skip-jwt-bearer-tokens: true + # Skip authentication for kagent's branded login page, health checks, and static assets + # This allows unauthenticated users to see the landing page and k8s probes to work + skip-auth-route: "^/(health|login)$" + skip-auth-regex: "^/(login|_next/static|_next/image|login-bg\\.(jpg|png|webp)|logo-.*\\.png|favicon\\.ico).*$" + # Use custom templates that redirect to kagent's branded /login page + custom-templates-dir: "/templates" + + service: + type: ClusterIP + portNumber: 4180 + # ============================================================================== # OBSERVABILITY # ============================================================================== diff --git a/ui/package.json b/ui/package.json index 4501c8e94..f5544adc4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -37,6 +37,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "jose": "^5.9.6", "lucide-react": "^0.562.0", "next": "^16.1.4", "next-themes": "^0.4.6", diff --git a/ui/public/login-bg.webp b/ui/public/login-bg.webp new file mode 100644 index 000000000..cb73ef775 Binary files /dev/null and b/ui/public/login-bg.webp differ diff --git a/ui/src/app/a2a/[namespace]/[agentName]/route.ts b/ui/src/app/a2a/[namespace]/[agentName]/route.ts index 7d94cf880..dd3816335 100644 --- a/ui/src/app/a2a/[namespace]/[agentName]/route.ts +++ b/ui/src/app/a2a/[namespace]/[agentName]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getBackendUrl } from '@/lib/utils'; +import { getAuthHeadersFromRequest } from '@/lib/auth'; export async function POST( request: NextRequest, @@ -13,6 +14,9 @@ export async function POST( const backendUrl = getBackendUrl(); const targetUrl = `${backendUrl}/a2a/${namespace}/${agentName}/`; + // Get auth headers from incoming request + const authHeaders = getAuthHeadersFromRequest(request); + const backendResponse = await fetch(targetUrl, { method: 'POST', headers: { @@ -21,6 +25,7 @@ export async function POST( 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'User-Agent': 'kagent-ui', + ...authHeaders, }, body: JSON.stringify(a2aRequest), }); diff --git a/ui/src/app/actions/auth.ts b/ui/src/app/actions/auth.ts new file mode 100644 index 000000000..6cdc8e35a --- /dev/null +++ b/ui/src/app/actions/auth.ts @@ -0,0 +1,24 @@ +"use server"; + +import { headers } from "next/headers"; +import { decodeJWT, isTokenExpired } from "@/lib/jwt"; + +export type CurrentUser = Record; + +export async function getCurrentUser(): Promise { + const headersList = await headers(); + const authHeader = headersList.get("Authorization"); + + if (!authHeader?.startsWith("Bearer ")) { + return null; + } + + const token = authHeader.slice(7); + const claims = decodeJWT(token); + + if (!claims || isTokenExpired(claims)) { + return null; + } + + return claims as CurrentUser; +} diff --git a/ui/src/app/actions/feedback.ts b/ui/src/app/actions/feedback.ts index db194809d..0e4670649 100644 --- a/ui/src/app/actions/feedback.ts +++ b/ui/src/app/actions/feedback.ts @@ -1,20 +1,18 @@ 'use server' import { FeedbackData, FeedbackIssueType } from "@/types"; -import { fetchApi, getCurrentUserId } from "./utils"; +import { fetchApi } from "./utils"; /** * Submit feedback to the server */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function submitFeedback(feedbackData: FeedbackData): Promise { - const userID = await getCurrentUserId(); - const body = { + const body = { is_positive: feedbackData.isPositive, feedback_text: feedbackData.feedbackText, issue_type: feedbackData.issueType, message_id: feedbackData.messageId, - user_id: userID }; return await fetchApi('/feedback', { method: 'POST', diff --git a/ui/src/app/actions/utils.ts b/ui/src/app/actions/utils.ts index 4393e2744..493f3fbfb 100644 --- a/ui/src/app/actions/utils.ts +++ b/ui/src/app/actions/utils.ts @@ -1,9 +1,5 @@ import { getBackendUrl } from "@/lib/utils"; - -export async function getCurrentUserId() { - // TODO: this should come from login state - return "admin@kagent.dev"; -} +import { getAuthHeadersFromContext } from "@/lib/auth"; type ApiOptions = RequestInit & { method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; @@ -17,19 +13,21 @@ type ApiOptions = RequestInit & { * @throws Error with a descriptive message if the request fails */ export async function fetchApi(path: string, options: ApiOptions = {}): Promise { - const userId = await getCurrentUserId(); // Ensure path starts with a slash const cleanPath = path.startsWith("/") ? path : `/${path}`; const url = `${getBackendUrl()}${cleanPath}`; - const urlWithUser = url.includes("?") ? `${url}&user_id=${userId}` : `${url}?user_id=${userId}`; - + + // Get auth headers from incoming request (set by proxy) + const authHeaders = await getAuthHeadersFromContext(); + try { - const response = await fetch(urlWithUser, { + const response = await fetch(url, { ...options, cache: "no-store", headers: { "Content-Type": "application/json", Accept: "application/json", + ...authHeaders, ...options.headers, }, signal: AbortSignal.timeout(30000), // 30 second timeout @@ -52,7 +50,7 @@ export async function fetchApi(path: string, options: ApiOptions = {}): Promi // If we can't parse the error response, use the default error message console.warn("Could not parse error response:", parseError); } - + throw new Error(errorMessage); } diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 43f9c75e0..175d0e4fb 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist } from "next/font/google"; import "./globals.css"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AgentsProvider } from "@/components/AgentsProvider"; +import { AuthProvider } from "@/contexts/AuthContext"; import { Header } from "@/components/Header"; import { Footer } from "@/components/Footer"; import { ThemeProvider } from "@/components/ThemeProvider"; @@ -22,18 +23,20 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - - - - -
-
{children}
-