From c9ded315bcac11aebe3372f011da27cad9eae0c8 Mon Sep 17 00:00:00 2001 From: Admin Bot Date: Tue, 8 Sep 2026 17:05:13 -0700 Subject: [PATCH] feat(proxy): add /auth/exchange token exchange endpoint POST /auth/exchange: service presents user JWT + own credentials, gets scoped service token with subject identity metadata. Flow: validate subject token, authenticate service via client_credentials, verify scope subset, return token + metadata. Scope escalation denied if requested scope exceeds service roles. 14 tests covering: helpers, validation, missing fields, scope subset, JWT decoding, edge cases. Closes homelab#10 Co-authored-by: poimen --- internal/proxy/proxy.go | 4 + internal/proxy/token_exchange.go | 229 ++++++++++++++++++++++++++ internal/proxy/token_exchange_test.go | 215 ++++++++++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 internal/proxy/token_exchange.go create mode 100644 internal/proxy/token_exchange_test.go diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 4adadf7..aaaeeab 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -243,6 +243,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.handleAuthRefresh(w, r) return } + if r.URL.Path == "/auth/exchange" { + h.handleAuthExchange(w, r) + return + } // Handle /v1/models endpoint (no routing needed, derived from config) if r.URL.Path == "/v1/models" && r.Method == "GET" { diff --git a/internal/proxy/token_exchange.go b/internal/proxy/token_exchange.go new file mode 100644 index 0000000..19f64fd --- /dev/null +++ b/internal/proxy/token_exchange.go @@ -0,0 +1,229 @@ +package proxy + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "forgejo.riotpiao.com/rock/homelab-frontend/internal/logging" +) + +// exchangeRequest represents a token exchange request (RFC 8693 subset). +type exchangeRequest struct { + SubjectToken string `json:"subject_token"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Scope string `json:"scope,omitempty"` + Resource string `json:"resource,omitempty"` +} + +// exchangeResponse wraps the service token with subject identity metadata. +type exchangeResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + IssuedTokenType string `json:"issued_token_type,omitempty"` + Subject string `json:"subject,omitempty"` + ActingParty string `json:"acting_party,omitempty"` + GrantedScope string `json:"scope,omitempty"` +} + +// handleAuthExchange implements token exchange: a service presents a user's +// JWT and its own credentials to get a scoped service token with the user's +// identity attached. +// +// Flow: +// 1. Validate subject_token (user's JWT) — signature, expiry, issuer +// 2. Authenticate service via client_credentials against Authentik +// 3. Verify requested scope is a subset of service's roles +// 4. Return service token + subject identity metadata +func (h *Handler) handleAuthExchange(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeProblemDetail(w, http.StatusMethodNotAllowed, + "about:blank#method-not-allowed", "Method Not Allowed", + "POST only", nil) + return + } + + if h.jwtValidator == nil || h.config.Auth.TokenURL == "" { + writeProblemDetail(w, http.StatusServiceUnavailable, + "about:blank#not-configured", "Not Configured", + "token exchange not configured", nil) + return + } + + var req exchangeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeProblemDetail(w, http.StatusBadRequest, + "about:blank#bad-request", "Bad Request", + "invalid JSON body", nil) + return + } + + if req.SubjectToken == "" || req.ClientID == "" || req.ClientSecret == "" { + writeProblemDetail(w, http.StatusBadRequest, + "about:blank#bad-request", "Bad Request", + "subject_token, client_id, and client_secret are required", nil) + return + } + + // Step 1: Validate subject token + subjectClaims, err := h.jwtValidator.ValidateBearerToken("Bearer " + req.SubjectToken) + if err != nil { + writeProblemDetail(w, http.StatusForbidden, + "about:blank#invalid-subject-token", "Invalid Subject Token", + fmt.Sprintf("subject token validation failed: %v", err), nil) + logging.Errorf("token exchange: invalid subject", err, map[string]string{ + "path": "/auth/exchange", + }) + return + } + + subject := claimStr(subjectClaims, "sub") + + // Step 2: Authenticate service via client_credentials + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {req.ClientID}, + "client_secret": {req.ClientSecret}, + "scope": {"openid roles"}, + } + + resp, err := h.authHTTP.PostForm(h.config.Auth.TokenURL, form) + if err != nil { + writeProblemDetail(w, http.StatusBadGateway, + "about:blank#bad-gateway", "Bad Gateway", + "identity provider unreachable", nil) + logging.Errorf("token exchange: upstream error", err, map[string]string{ + "path": "/auth/exchange", + }) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + writeProblemDetail(w, http.StatusForbidden, + "about:blank#invalid-actor", "Invalid Actor Credentials", + fmt.Sprintf("service authentication failed (HTTP %d)", resp.StatusCode), nil) + return + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + writeProblemDetail(w, http.StatusBadGateway, + "about:blank#bad-gateway", "Bad Gateway", + "invalid response from identity provider", nil) + return + } + + // Step 3: Decode service token to check roles + serviceRoles, err := extractRolesFromJWT(tokenResp.AccessToken) + if err != nil { + writeProblemDetail(w, http.StatusBadGateway, + "about:blank#bad-gateway", "Bad Gateway", + "cannot decode service token", nil) + return + } + + if req.Scope != "" && !scopeSubset(req.Scope, serviceRoles) { + writeProblemDetail(w, http.StatusForbidden, + "about:blank#scope-escalation", "Scope Escalation Denied", + fmt.Sprintf("requested scope %q exceeds service roles %v", req.Scope, serviceRoles), nil) + return + } + + grantedScope := req.Scope + if grantedScope == "" { + grantedScope = strings.Join(serviceRoles, " ") + } + + // Step 4: Return service token with subject metadata + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(exchangeResponse{ + AccessToken: tokenResp.AccessToken, + TokenType: "Bearer", + ExpiresIn: tokenResp.ExpiresIn, + IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token", + Subject: subject, + ActingParty: req.ClientID, + GrantedScope: grantedScope, + }) +} + +// scopeSubset checks that every space-separated scope token is in allowed roles. +func scopeSubset(requested string, allowed []string) bool { + allowedSet := make(map[string]bool, len(allowed)) + for _, r := range allowed { + allowedSet[r] = true + } + if allowedSet["*"] { + return true + } + for _, s := range strings.Fields(requested) { + if !allowedSet[s] { + return false + } + } + return true +} + +// extractRolesFromJWT decodes the payload of a JWT without verification +// and returns the "roles" claim. Used after the token was already obtained +// from a trusted source (Authentik client_credentials response). +func extractRolesFromJWT(token string) ([]string, error) { + parts := strings.SplitN(token, ".", 3) + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("base64 decode failed: %w", err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("JSON decode failed: %w", err) + } + + return claimStrSlice(claims, "roles"), nil +} + +// claimStr extracts a string claim. +func claimStr(claims map[string]interface{}, key string) string { + v, ok := claims[key] + if !ok || v == nil { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return s +} + +// claimStrSlice extracts a string slice from a JSON-deserialized []interface{}. +func claimStrSlice(claims map[string]interface{}, key string) []string { + v, ok := claims[key] + if !ok || v == nil { + return nil + } + raw, ok := v.([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out +} diff --git a/internal/proxy/token_exchange_test.go b/internal/proxy/token_exchange_test.go new file mode 100644 index 0000000..8c477a5 --- /dev/null +++ b/internal/proxy/token_exchange_test.go @@ -0,0 +1,215 @@ +package proxy + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "forgejo.riotpiao.com/rock/homelab-frontend/internal/auth" + "forgejo.riotpiao.com/rock/homelab-frontend/internal/config" +) + +// fakeJWT creates a JWT-shaped string (header.payload.signature) with given claims. +// Not cryptographically signed — used only with stubbed validators. +func fakeJWT(claims map[string]interface{}) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + payload, _ := json.Marshal(claims) + payloadB64 := base64.RawURLEncoding.EncodeToString(payload) + return header + "." + payloadB64 + ".fakesig" +} + +// stubJWTValidator returns claims from a pre-set map keyed by token. +type stubJWTValidator struct { + tokens map[string]map[string]interface{} +} + +func (s *stubJWTValidator) ValidateBearerToken(authHeader string) (map[string]interface{}, error) { + token := strings.TrimPrefix(authHeader, "Bearer ") + if claims, ok := s.tokens[token]; ok { + return claims, nil + } + return nil, fmt.Errorf("invalid token") +} + +func (s *stubJWTValidator) CheckPermissions(claims map[string]interface{}, required ...string) bool { + return true +} + +// We can't use stubJWTValidator directly because Handler expects *auth.Validator. +// Instead, test via the endpoint with a real JWKS server or test the helpers directly. + +func TestScopeSubset(t *testing.T) { + tests := []struct { + requested string + allowed []string + want bool + }{ + {"memory:read", []string{"llm:inference", "memory:read"}, true}, + {"memory:read memory:write", []string{"memory:read", "memory:write"}, true}, + {"memory:write", []string{"memory:read"}, false}, + {"admin:*", []string{"memory:read"}, false}, + {"anything", []string{"*"}, true}, + {"", []string{"memory:read"}, true}, + {"memory:read", []string{}, false}, + } + for _, tt := range tests { + got := scopeSubset(tt.requested, tt.allowed) + if got != tt.want { + t.Errorf("scopeSubset(%q, %v) = %v, want %v", tt.requested, tt.allowed, got, tt.want) + } + } +} + +func TestExtractRolesFromJWT(t *testing.T) { + token := fakeJWT(map[string]interface{}{ + "roles": []interface{}{"llm:inference", "memory:read"}, + "sub": "test-user", + }) + + roles, err := extractRolesFromJWT(token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(roles) != 2 || roles[0] != "llm:inference" || roles[1] != "memory:read" { + t.Errorf("roles = %v, want [llm:inference memory:read]", roles) + } +} + +func TestExtractRolesFromJWT_InvalidFormat(t *testing.T) { + _, err := extractRolesFromJWT("not-a-jwt") + if err == nil { + t.Error("expected error for invalid JWT") + } +} + +func TestExtractRolesFromJWT_NoRoles(t *testing.T) { + token := fakeJWT(map[string]interface{}{"sub": "user"}) + roles, err := extractRolesFromJWT(token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if roles != nil { + t.Errorf("expected nil roles, got %v", roles) + } +} + +func TestHandleAuthExchange_WrongMethod(t *testing.T) { + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) + h.jwtValidator = auth.NewValidator("", "", "") + + r := httptest.NewRequest("GET", "/auth/exchange", nil) + w := httptest.NewRecorder() + + h.handleAuthExchange(w, r) + + if w.Code != 405 { + t.Errorf("expected 405, got %d", w.Code) + } +} + +func TestHandleAuthExchange_NotConfigured(t *testing.T) { + h := &Handler{ + config: &config.Config{}, + routes: make(map[string]*Route), + transports: make(map[string]*http.Transport), + } + + body := `{"subject_token":"x","client_id":"y","client_secret":"z"}` + r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.handleAuthExchange(w, r) + + if w.Code != 503 { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestHandleAuthExchange_MissingFields(t *testing.T) { + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) + h.jwtValidator = auth.NewValidator("", "", "") + + tests := []struct { + name string + body string + }{ + {"missing subject", `{"client_id":"x","client_secret":"y"}`}, + {"missing client_id", `{"subject_token":"x","client_secret":"y"}`}, + {"missing client_secret", `{"subject_token":"x","client_id":"y"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader(tt.body)) + w := httptest.NewRecorder() + h.handleAuthExchange(w, r) + if w.Code != 400 { + t.Errorf("expected 400, got %d", w.Code) + } + }) + } +} + +func TestHandleAuthExchange_InvalidJSON(t *testing.T) { + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) + h.jwtValidator = auth.NewValidator("", "", "") + + r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader("not json")) + w := httptest.NewRecorder() + + h.handleAuthExchange(w, r) + + if w.Code != 400 { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestHandleAuthExchange_ScopeEscalation(t *testing.T) { + // Test scopeSubset directly since full integration needs JWKS + if scopeSubset("admin:delete", []string{"memory:read", "memory:write"}) { + t.Error("scope escalation should be denied") + } + if !scopeSubset("memory:read", []string{"memory:read", "memory:write"}) { + t.Error("valid scope should be allowed") + } +} + +func TestClaimStr(t *testing.T) { + claims := map[string]interface{}{"sub": "user-1", "num": 42, "nil": nil} + if got := claimStr(claims, "sub"); got != "user-1" { + t.Errorf("claimStr(sub) = %q, want user-1", got) + } + if got := claimStr(claims, "num"); got != "" { + t.Errorf("claimStr(num) = %q, want empty", got) + } + if got := claimStr(claims, "nil"); got != "" { + t.Errorf("claimStr(nil) = %q, want empty", got) + } + if got := claimStr(claims, "missing"); got != "" { + t.Errorf("claimStr(missing) = %q, want empty", got) + } +} + +func TestClaimStrSlice(t *testing.T) { + claims := map[string]interface{}{ + "roles": []interface{}{"a", "b", "", nil, 42}, + "empty": []interface{}{}, + "str": "not-a-slice", + } + if got := claimStrSlice(claims, "roles"); len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Errorf("claimStrSlice(roles) = %v, want [a b]", got) + } + if got := claimStrSlice(claims, "empty"); len(got) != 0 { + t.Errorf("claimStrSlice(empty) = %v, want empty", got) + } + if got := claimStrSlice(claims, "str"); got != nil { + t.Errorf("claimStrSlice(str) = %v, want nil", got) + } + if got := claimStrSlice(claims, "missing"); got != nil { + t.Errorf("claimStrSlice(missing) = %v, want nil", got) + } +} -- 2.54.0