@@ -20,6 +20,49 @@ type RemoteContributor struct {
2020 lastFetch time.Time
2121}
2222
23+ // forwardedHeadersKey is the context key the proxy uses to pass through
24+ // inbound-request headers (typically `Authorization` and `Cookie`) on
25+ // the server-to-server call to a remote contributor. Without this, the
26+ // remote sees an unauthenticated S2S request and cannot identify the
27+ // end user — that breaks any contributor handler that needs the
28+ // caller's identity (e.g. authsome's dashboard "Create API Key" form,
29+ // which binds the new key to the dashboard user's UserID).
30+ type forwardedHeadersKey struct {}
31+
32+ // WithForwardedHeaders returns a derived context that carries the
33+ // supplied headers; RemoteContributor.send forwards a fixed allowlist
34+ // of them (Authorization, Cookie) on the outbound HTTP request. Pass
35+ // the inbound *http.Request.Header verbatim — only the allowlisted
36+ // keys are forwarded, so unrelated headers (Host, Content-Length, …)
37+ // are not leaked.
38+ //
39+ // Trust model: the host explicitly registers a remote contributor via
40+ // WatchRemoteContributor / AddRemoteContributor, so forwarding the
41+ // end user's auth to that registered URL is in-scope of an existing
42+ // trust boundary. Do NOT forward to arbitrary URLs.
43+ func WithForwardedHeaders (ctx context.Context , h http.Header ) context.Context {
44+ if h == nil {
45+ return ctx
46+ }
47+
48+ return context .WithValue (ctx , forwardedHeadersKey {}, h .Clone ())
49+ }
50+
51+ // forwardedHeadersFrom returns the forwarded headers attached to ctx,
52+ // or nil if none were set.
53+ func forwardedHeadersFrom (ctx context.Context ) http.Header {
54+ v , _ := ctx .Value (forwardedHeadersKey {}).(http.Header ) //nolint:errcheck // type-safe via key
55+
56+ return v
57+ }
58+
59+ // forwardableHeaderNames lists the inbound headers RemoteContributor
60+ // will forward on S2S calls. Authorization carries Bearer tokens (JWT
61+ // or session), Cookie carries the host-side session cookie when the
62+ // dashboard uses cookie auth. Anything else is dropped to avoid
63+ // leaking unrelated request metadata to the remote.
64+ var forwardableHeaderNames = []string {"Authorization" , "Cookie" }
65+
2366// RemoteContributorOption configures a RemoteContributor.
2467type RemoteContributorOption func (* RemoteContributor )
2568
@@ -77,6 +120,20 @@ func (rc *RemoteContributor) FetchPage(ctx context.Context, route, rawQuery stri
77120 return rc .fetch (ctx , url )
78121}
79122
123+ // PostPage forwards a POST to the remote contributor's page endpoint.
124+ // Used to proxy form submissions from the host dashboard. The remote handler
125+ // re-renders the page with FormData populated from the parsed body. body and
126+ // contentType come straight from the inbound request — typically
127+ // application/x-www-form-urlencoded or multipart/form-data.
128+ func (rc * RemoteContributor ) PostPage (ctx context.Context , route , rawQuery string , body io.Reader , contentType string ) ([]byte , error ) {
129+ url := rc .baseURL + "/_forge/dashboard/pages" + route
130+ if rawQuery != "" {
131+ url += "?" + rawQuery
132+ }
133+
134+ return rc .send (ctx , http .MethodPost , url , body , contentType )
135+ }
136+
80137// FetchWidget fetches an HTML widget fragment from the remote service.
81138func (rc * RemoteContributor ) FetchWidget (ctx context.Context , widgetID string ) ([]byte , error ) {
82139 url := rc .baseURL + "/_forge/dashboard/widgets/" + widgetID
@@ -91,9 +148,15 @@ func (rc *RemoteContributor) FetchSettings(ctx context.Context, settingID string
91148 return rc .fetch (ctx , url )
92149}
93150
94- // fetch makes an authenticated HTTP request and returns the response body.
151+ // fetch makes an authenticated GET request and returns the response body.
95152func (rc * RemoteContributor ) fetch (ctx context.Context , url string ) ([]byte , error ) {
96- req , err := http .NewRequestWithContext (ctx , http .MethodGet , url , nil )
153+ return rc .send (ctx , http .MethodGet , url , nil , "" )
154+ }
155+
156+ // send executes an authenticated request against the remote contributor's
157+ // protocol with the given method/body. Returns the response body bytes.
158+ func (rc * RemoteContributor ) send (ctx context.Context , method , url string , body io.Reader , contentType string ) ([]byte , error ) {
159+ req , err := http .NewRequestWithContext (ctx , method , url , body )
97160 if err != nil {
98161 return nil , fmt .Errorf ("failed to create request: %w" , err )
99162 }
@@ -102,10 +165,28 @@ func (rc *RemoteContributor) fetch(ctx context.Context, url string) ([]byte, err
102165 req .Header .Set ("X-Forge-Dashboard" , "true" )
103166 req .Header .Set ("Accept" , "text/html" )
104167
168+ if contentType != "" {
169+ req .Header .Set ("Content-Type" , contentType )
170+ }
171+
105172 if rc .apiKey != "" {
106173 req .Header .Set ("X-Forge-Api-Key" , rc .apiKey )
107174 }
108175
176+ // Forward the allowlisted inbound headers (e.g. Authorization,
177+ // Cookie) when the host has populated them via
178+ // WithForwardedHeaders. The remote contributor's auth middleware
179+ // reads these to resolve the end user — without it, S2S calls
180+ // from the host arrive unauthenticated and any handler that
181+ // needs a UserID has no choice but to fail or fabricate one.
182+ if fwd := forwardedHeadersFrom (ctx ); fwd != nil {
183+ for _ , name := range forwardableHeaderNames {
184+ if v := fwd .Get (name ); v != "" {
185+ req .Header .Set (name , v )
186+ }
187+ }
188+ }
189+
109190 resp , err := rc .client .Do (req )
110191 if err != nil {
111192 return nil , fmt .Errorf ("remote request failed: %w" , err )
@@ -116,14 +197,14 @@ func (rc *RemoteContributor) fetch(ctx context.Context, url string) ([]byte, err
116197 return nil , fmt .Errorf ("remote returned status %d for %s" , resp .StatusCode , url )
117198 }
118199
119- body , err := io .ReadAll (io .LimitReader (resp .Body , 5 * 1024 * 1024 )) // 5MB limit
200+ respBody , err := io .ReadAll (io .LimitReader (resp .Body , 5 * 1024 * 1024 )) // 5MB limit
120201 if err != nil {
121202 return nil , fmt .Errorf ("failed to read response body: %w" , err )
122203 }
123204
124205 rc .lastFetch = time .Now ()
125206
126- return body , nil
207+ return respBody , nil
127208}
128209
129210// FetchManifest fetches the dashboard manifest from a remote service URL.
0 commit comments