diff --git a/frontend/internal/api/client.go b/frontend/internal/api/client.go index 780d5cd..fb985c8 100644 --- a/frontend/internal/api/client.go +++ b/frontend/internal/api/client.go @@ -115,8 +115,9 @@ func (c *HubClient) BaseURL() string { // // The headers forwarded are: // - X-Label-Hub-Key — API-key auth (Phase 7c) -// - X-Pangolin-User — Pangolin SSO session header (Legacy / Standard SSO) +// - X-Pangolin-User — Pangolin SSO session header (Legacy) // - X-Pangolin-Token — Pangolin Resource custom upstream header (Trust-Token) +// - Remote-User — Pangolin Standard-SSO User-Identity (sso_user_header) // - Authorization — Pangolin Basic-Auth bypass (claude-automation) // // This is required because the frontend-to-backend calls are internal @@ -126,15 +127,17 @@ func (c *HubClient) BaseURL() string { // X-Pangolin-Token is the static trust-token that a Pangolin Resource can // inject into every upstream request via its Header-Auth configuration. The // backend's sso_trust_header mechanism accepts it as a SSO-equivalent signal, -// which is what allows browser users without an active SSO session to still -// reach the UI. +// but only when paired with Remote-User (the user identity from Pangolin's +// standard SSO header). Forwarding only one of the two triggers a 401 — both +// must be forwarded together for browser users with active Pangolin SSO +// sessions to reach the UI. // // The original HubClient is not mutated; the returned copy shares the same // underlying http.Client and gen client but adds a RequestEditorFn that // injects the auth headers. func (c *HubClient) WithAuthFrom(r *http.Request) *HubClient { - headers := make(map[string]string, 4) - for _, h := range []string{"X-Label-Hub-Key", "X-Pangolin-User", "X-Pangolin-Token", "Authorization"} { + headers := make(map[string]string, 5) + for _, h := range []string{"X-Label-Hub-Key", "X-Pangolin-User", "X-Pangolin-Token", "Remote-User", "Authorization"} { if v := r.Header.Get(h); v != "" { headers[h] = v } diff --git a/frontend/internal/api/client_test.go b/frontend/internal/api/client_test.go index 55a3e09..f3a08ec 100644 --- a/frontend/internal/api/client_test.go +++ b/frontend/internal/api/client_test.go @@ -265,3 +265,44 @@ func TestWithAuthFromForwardsPangolinTokenHeader(t *testing.T) { t.Errorf("X-Pangolin-Token forwarded as %q, want %q", receivedToken, "pangolin-trust-token-abc123") } } + +// TestWithAuthFromForwardsRemoteUserHeader verifies that WithAuthFrom +// propagates the Remote-User header (Pangolin Standard-SSO User-Identity) +// to the backend. The backend's SSO-trust path requires BOTH X-Pangolin-Token +// (trust signal) AND Remote-User (identity) — forwarding only one of them +// triggers a 401. Without forwarding Remote-User, browser users with active +// Pangolin SSO session still see 503 on every UI route that needs backend +// data, because the backend cannot identify them. +func TestWithAuthFromForwardsRemoteUserHeader(t *testing.T) { + t.Parallel() + var receivedUser string + now := time.Now().Format(time.RFC3339) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/printers" { + receivedUser = r.Header.Get("Remote-User") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]map[string]any{ + {"id": "aaaaaaaa-0000-0000-0000-000000000001", "name": "PT-P750W", + "model": "pt_series", "backend": "tcp", + "connection": map[string]any{"host": "198.51.100.10", "port": 9100}, + "enabled": true, "paused": false, + "created_at": now, "updated_at": now}, + }) + return + } + http.NotFound(w, r) + })) + defer backend.Close() + + incomingReq := httptest.NewRequest(http.MethodGet, "/", nil) + incomingReq.Header.Set("Remote-User", "strausmann") + + client := api.NewHubClient(backend.URL).WithAuthFrom(incomingReq) + _, err := client.ListPrinters(context.Background()) + if err != nil { + t.Fatalf("ListPrinters: %v", err) + } + if receivedUser != "strausmann" { + t.Errorf("Remote-User forwarded as %q, want %q", receivedUser, "strausmann") + } +}