-
Notifications
You must be signed in to change notification settings - Fork 439
OIDC proxy auth #1293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
OIDC proxy auth #1293
Changes from all commits
fcb1244
c719dc1
852f6ae
2ebddb4
571a9a1
101cc50
da15970
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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" | ||
| } | ||
| } |
| 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) | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This drops |
||
| 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 | ||
| } | ||
There was a problem hiding this comment.
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-proxyinstead of just proxy because it's explicitly not re-validating