Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions docs/OIDC_PROXY_AUTH_ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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 <JWT>
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<br/>(Cognito, Okta, etc.)"]
end

subgraph Proxy["Authentication Proxy Layer"]
OAuth2Proxy["oauth2-proxy<br/>- Session management<br/>- Token refresh<br/>- JWT injection"]
end

subgraph UI["UI Layer (Next.js)"]
LoginPage["/login Page<br/>SSO redirect button"]
AuthContext["AuthContext Provider<br/>- User state management<br/>- Loading/error states"]
AuthActions["Server Actions<br/>getCurrentUser()"]
JWTLib["JWT Library<br/>- Decode tokens<br/>- Check expiry"]
AuthLib["Auth Library<br/>- Header forwarding"]
end

subgraph Backend["Backend Layer (Go)"]
ProxyAuth["ProxyAuthenticator<br/>- Raw JWT claims passthrough<br/>- Service account fallback"]
HTTPServer["HTTP Server<br/>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:<br/>Valid session?}
B -->|No| C[Redirect to OIDC provider]
B -->|Yes| D[Inject JWT header]
D --> E[Forward to UI/Backend]
E --> F{AuthContext:<br/>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
58 changes: 58 additions & 0 deletions go/cmd/controller/auth_mode_test.go
Original file line number Diff line number Diff line change
@@ -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"
}
}
12 changes: 11 additions & 1 deletion go/cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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{}
}
}
113 changes: 113 additions & 0 deletions go/internal/httpserver/auth/proxy_authn.go
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +36 to +37
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we call this mode trusted-proxy instead of just proxy because it's explicitly not re-validating

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
Comment on lines +66 to +85
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably create a follow-up for this to make sure it's more secure

}

func (a *ProxyAuthenticator) UpstreamAuth(r *http.Request, session auth.Session, upstreamPrincipal auth.Principal) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This drops X-User-Id on controller-to-agent A2A calls. The downstream A2A runtimes still derive the caller from that header, so in trusted-proxy mode the real user becomes A2A_USER_<context_id> after the first hop. We likely need to forward the resolved user id here in addition to the bearer token.

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
}
Loading
Loading