Skip to content

Commit 49bfd34

Browse files
Make request headers and query params available to the HTTP Request Executor
Refs #1354 Signed-off-by: thisarawelmilla <thisara@wso2.com>
1 parent f24e9ed commit 49bfd34

11 files changed

Lines changed: 502 additions & 2 deletions

File tree

backend/internal/flow/core/utils.go

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,19 @@ import (
1616
// TODO: Extend to support {{user(key)}}, {{env(key)}}, etc.
1717
var placeholderPattern = regexp.MustCompile(`{{\s*ctx\(\s*(\w+)\s*\)\s*}}`)
1818

19-
// ResolvePlaceholder resolves a single placeholder string using the "{{ctx(key)}}" syntax.
19+
// requestPlaceholderPattern matches {{request(<selector>)}} with optional whitespace, capturing the
20+
// raw selector so it can be parsed into an optional source, a type (header/query) and a name.
21+
var requestPlaceholderPattern = regexp.MustCompile(`{{\s*request\(\s*([^)]*?)\s*\)\s*}}`)
22+
23+
// Request selector source tokens used in {{request(...)}} placeholders. requestSourceInit refers
24+
// to the flow-initiation request (the default) and requestSourceFlow to the current flow step request.
25+
const (
26+
requestSourceInit = "init"
27+
requestSourceFlow = "flow"
28+
)
29+
30+
// ResolvePlaceholder resolves a single placeholder string using the "{{ctx(key)}}" and
31+
// "{{request(...)}}" syntaxes.
2032
// If no placeholder is found, the original value is returned.
2133
// If a placeholder is found but the key doesn't exist in any data source, the placeholder is kept as-is.
2234
func ResolvePlaceholder(ctx *providers.NodeContext, value string, execResp *providers.ExecutorResponse,
@@ -27,7 +39,10 @@ func ResolvePlaceholder(ctx *providers.NodeContext, value string, execResp *prov
2739

2840
var contextUserRef *providers.EntityReference
2941

30-
return placeholderPattern.ReplaceAllStringFunc(value, func(match string) string {
42+
// Resolve {{ctx(...)}} first, then {{request(...)}}. Because ReplaceAllStringFunc does not
43+
// re-scan its own output, resolving request placeholders last keeps any {{ctx(...)}}-looking
44+
// text that a request header or query value happens to contain from being resolved as context.
45+
value = placeholderPattern.ReplaceAllStringFunc(value, func(match string) string {
3146
submatches := placeholderPattern.FindStringSubmatch(match)
3247
if len(submatches) < 2 {
3348
return match
@@ -77,6 +92,97 @@ func ResolvePlaceholder(ctx *providers.NodeContext, value string, execResp *prov
7792
// If not found, keep the placeholder as-is
7893
return match
7994
})
95+
96+
return resolveRequestPlaceholders(ctx, value)
97+
}
98+
99+
// resolveRequestPlaceholders resolves {{request(...)}} placeholders using the HTTP request data
100+
// carried on the node context. The selector is "[source.]type.name" where source is "init"
101+
// (flow-initiation request, the default) or "flow" (current flow step request), and type is
102+
// "header" or "query". Header lookups are case-insensitive per RFC 7230; query lookups are
103+
// case-sensitive. When multiple values exist, the first is returned. Unresolvable placeholders
104+
// (missing request, unknown source/type, or absent name) are kept as-is.
105+
func resolveRequestPlaceholders(ctx *providers.NodeContext, value string) string {
106+
return requestPlaceholderPattern.ReplaceAllStringFunc(value, func(match string) string {
107+
submatches := requestPlaceholderPattern.FindStringSubmatch(match)
108+
if len(submatches) < 2 {
109+
return match
110+
}
111+
112+
source, kind, name, ok := parseRequestSelector(submatches[1])
113+
if !ok {
114+
return match
115+
}
116+
117+
var req *providers.InitiatorRequest
118+
switch source {
119+
case requestSourceInit:
120+
req = ctx.GetInitiatorRequest()
121+
case requestSourceFlow:
122+
req = ctx.GetCurrentRequest()
123+
}
124+
if req == nil {
125+
return match
126+
}
127+
128+
var resolved string
129+
var found bool
130+
switch kind {
131+
case "header":
132+
resolved, found = firstHeaderValue(req.Headers, name)
133+
case "query":
134+
resolved, found = firstValue(req.QueryParams[name])
135+
}
136+
if !found {
137+
return match
138+
}
139+
return resolved
140+
})
141+
}
142+
143+
// parseRequestSelector splits a request selector into its source, type and name components.
144+
// It accepts "type.name" (source defaults to "init") or "source.type.name". source must be
145+
// "init" or "flow" and type must be "header" or "query"; name is the remainder and may contain dots.
146+
func parseRequestSelector(selector string) (source, kind, name string, ok bool) {
147+
source = requestSourceInit
148+
149+
head, rest, hasRest := strings.Cut(selector, ".")
150+
if !hasRest {
151+
return "", "", "", false
152+
}
153+
if head == requestSourceInit || head == requestSourceFlow {
154+
source = head
155+
head, rest, hasRest = strings.Cut(rest, ".")
156+
if !hasRest {
157+
return "", "", "", false
158+
}
159+
}
160+
161+
if head != "header" && head != "query" {
162+
return "", "", "", false
163+
}
164+
if rest == "" {
165+
return "", "", "", false
166+
}
167+
return source, head, rest, true
168+
}
169+
170+
// firstHeaderValue returns the first value for a header, matching the name case-insensitively.
171+
func firstHeaderValue(headers map[string][]string, name string) (string, bool) {
172+
for key, values := range headers {
173+
if strings.EqualFold(key, name) {
174+
return firstValue(values)
175+
}
176+
}
177+
return "", false
178+
}
179+
180+
// firstValue returns the first element of a string slice.
181+
func firstValue(values []string) (string, bool) {
182+
if len(values) == 0 {
183+
return "", false
184+
}
185+
return values[0], true
80186
}
81187

82188
// fetchContextUserRef attempts to resolve the authenticated user's entity reference using the authn provider.

backend/internal/flow/core/utils_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,101 @@ func (s *UtilsTestSuite) TestResolvePlaceholderSpecialCharactersInValue() {
382382
})
383383
}
384384
}
385+
386+
func (s *UtilsTestSuite) TestResolveRequestPlaceholderFromInitiatorRequest() {
387+
ctx := &providers.NodeContext{}
388+
ctx.SetInitiatorRequest(&providers.InitiatorRequest{
389+
Headers: map[string][]string{"User-Agent": {"curl/8.0"}},
390+
QueryParams: map[string][]string{"utm_source": {"newsletter"}},
391+
})
392+
393+
tests := []struct {
394+
name string
395+
input string
396+
expected string
397+
}{
398+
{"Header default source", "{{request(header.User-Agent)}}", "curl/8.0"},
399+
{"Header explicit init source", "{{request(init.header.User-Agent)}}", "curl/8.0"},
400+
{"Header case-insensitive lookup", "{{request(header.user-agent)}}", "curl/8.0"},
401+
{"Query default source", "{{request(query.utm_source)}}", "newsletter"},
402+
{"Query explicit init source", "{{request(init.query.utm_source)}}", "newsletter"},
403+
{"Embedded in larger value", "ua={{request(header.User-Agent)}}", "ua=curl/8.0"},
404+
}
405+
406+
for _, tt := range tests {
407+
s.Run(tt.name, func() {
408+
result := ResolvePlaceholder(ctx, tt.input, nil, nil, nil)
409+
s.Equal(tt.expected, result)
410+
})
411+
}
412+
}
413+
414+
func (s *UtilsTestSuite) TestResolveRequestPlaceholderFromCurrentRequest() {
415+
ctx := &providers.NodeContext{}
416+
ctx.SetInitiatorRequest(&providers.InitiatorRequest{
417+
Headers: map[string][]string{"User-Agent": {"init-agent"}},
418+
})
419+
ctx.SetCurrentRequest(&providers.InitiatorRequest{
420+
Headers: map[string][]string{"User-Agent": {"flow-agent"}},
421+
QueryParams: map[string][]string{"step": {"otp"}},
422+
})
423+
424+
s.Equal("flow-agent", ResolvePlaceholder(ctx, "{{request(flow.header.User-Agent)}}", nil, nil, nil))
425+
s.Equal("otp", ResolvePlaceholder(ctx, "{{request(flow.query.step)}}", nil, nil, nil))
426+
// Default source stays init even when a current request is present.
427+
s.Equal("init-agent", ResolvePlaceholder(ctx, "{{request(header.User-Agent)}}", nil, nil, nil))
428+
}
429+
430+
func (s *UtilsTestSuite) TestResolveRequestPlaceholderQueryIsCaseSensitive() {
431+
ctx := &providers.NodeContext{}
432+
ctx.SetInitiatorRequest(&providers.InitiatorRequest{
433+
QueryParams: map[string][]string{"utm_source": {"newsletter"}},
434+
})
435+
436+
s.Equal("{{request(query.UTM_SOURCE)}}",
437+
ResolvePlaceholder(ctx, "{{request(query.UTM_SOURCE)}}", nil, nil, nil),
438+
"query lookups must be case-sensitive")
439+
}
440+
441+
func (s *UtilsTestSuite) TestResolveRequestPlaceholderFirstValueWins() {
442+
ctx := &providers.NodeContext{}
443+
ctx.SetInitiatorRequest(&providers.InitiatorRequest{
444+
Headers: map[string][]string{"X-Forwarded-For": {"1.1.1.1", "2.2.2.2"}},
445+
})
446+
447+
s.Equal("1.1.1.1", ResolvePlaceholder(ctx, "{{request(header.X-Forwarded-For)}}", nil, nil, nil))
448+
}
449+
450+
func (s *UtilsTestSuite) TestResolveRequestPlaceholderUnresolvable() {
451+
ctxWithReq := &providers.NodeContext{}
452+
ctxWithReq.SetInitiatorRequest(&providers.InitiatorRequest{
453+
Headers: map[string][]string{"User-Agent": {"curl/8.0"}},
454+
})
455+
456+
ctxWithoutReq := &providers.NodeContext{}
457+
458+
tests := []struct {
459+
name string
460+
ctx *providers.NodeContext
461+
input string
462+
expected string
463+
}{
464+
{"Nil initiator request", ctxWithoutReq, "{{request(header.User-Agent)}}",
465+
"{{request(header.User-Agent)}}"},
466+
{"Nil current request", ctxWithReq, "{{request(flow.header.User-Agent)}}",
467+
"{{request(flow.header.User-Agent)}}"},
468+
{"Unknown header", ctxWithReq, "{{request(header.X-Missing)}}", "{{request(header.X-Missing)}}"},
469+
{"Unknown source", ctxWithReq, "{{request(other.header.User-Agent)}}",
470+
"{{request(other.header.User-Agent)}}"},
471+
{"Unknown type", ctxWithReq, "{{request(cookie.session)}}", "{{request(cookie.session)}}"},
472+
{"Missing name", ctxWithReq, "{{request(header.)}}", "{{request(header.)}}"},
473+
{"Missing type separator", ctxWithReq, "{{request(header)}}", "{{request(header)}}"},
474+
}
475+
476+
for _, tt := range tests {
477+
s.Run(tt.name, func() {
478+
result := ResolvePlaceholder(tt.ctx, tt.input, nil, nil, nil)
479+
s.Equal(tt.expected, result)
480+
})
481+
}
482+
}

backend/internal/flow/executor/http_request_executor_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,56 @@ func (suite *HTTPRequestExecutorTestSuite) TestResolvePlaceholdersInConfig() {
122122
assert.Equal(suite.T(), "test@example.com", receivedBody["email"])
123123
}
124124

125+
func (suite *HTTPRequestExecutorTestSuite) TestResolveRequestPlaceholdersInConfig() {
126+
var receivedURL string
127+
var receivedHeaders http.Header
128+
var receivedBody map[string]interface{}
129+
130+
suite.mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131+
receivedURL = r.URL.RequestURI()
132+
receivedHeaders = r.Header
133+
err := json.NewDecoder(r.Body).Decode(&receivedBody)
134+
if err != nil {
135+
receivedBody = nil
136+
}
137+
w.WriteHeader(http.StatusOK)
138+
}))
139+
140+
ctx := &providers.NodeContext{
141+
ExecutionID: "test-flow",
142+
NodeProperties: map[string]interface{}{
143+
"url": suite.mockServer.URL + "/collect?src={{request(query.utm_source)}}",
144+
"method": "POST",
145+
"headers": map[string]interface{}{
146+
"X-Forwarded-Agent": "{{request(header.User-Agent)}}",
147+
"X-Step-Agent": "{{request(flow.header.User-Agent)}}",
148+
},
149+
"body": map[string]interface{}{
150+
"host": "{{request(header.Host)}}",
151+
"source": "{{request(init.query.utm_source)}}",
152+
},
153+
},
154+
}
155+
ctx.SetInitiatorRequest(&providers.InitiatorRequest{
156+
Headers: map[string][]string{"User-Agent": {"init-agent"}, "Host": {"id.example.com"}},
157+
QueryParams: map[string][]string{"utm_source": {"newsletter"}},
158+
})
159+
ctx.SetCurrentRequest(&providers.InitiatorRequest{
160+
Headers: map[string][]string{"User-Agent": {"step-agent"}},
161+
})
162+
163+
execResp, err := suite.executor.Execute(ctx)
164+
165+
assert.NoError(suite.T(), err)
166+
assert.Equal(suite.T(), providers.ExecComplete, execResp.Status)
167+
168+
assert.Equal(suite.T(), "/collect?src=newsletter", receivedURL)
169+
assert.Equal(suite.T(), "init-agent", receivedHeaders.Get("X-Forwarded-Agent"))
170+
assert.Equal(suite.T(), "step-agent", receivedHeaders.Get("X-Step-Agent"))
171+
assert.Equal(suite.T(), "id.example.com", receivedBody["host"])
172+
assert.Equal(suite.T(), "newsletter", receivedBody["source"])
173+
}
174+
125175
func (suite *HTTPRequestExecutorTestSuite) TestResolvePlaceholderUserIDSpecialHandling() {
126176
var receivedBody map[string]interface{}
127177

backend/internal/flow/flowexec/engine.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ func (fe *flowEngine) executeNodePackage(ctx *EngineContext,
196196
ExecutionHistory: ctx.ExecutionHistory,
197197
}
198198
nodeCtx.SetInitiatorRequest(ctx.GetInitiatorRequest())
199+
nodeCtx.SetCurrentRequest(ctx.GetCurrentRequest())
199200
if nodeCtx.NodeInputs == nil {
200201
nodeCtx.NodeInputs = make([]providers.Input, 0)
201202
}

backend/internal/flow/flowexec/handler.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/thunder-id/thunderid/internal/flow/session"
1212
tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common"
13+
"github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
1314

1415
serverconst "github.com/thunder-id/thunderid/internal/system/constants"
1516
"github.com/thunder-id/thunderid/internal/system/error/apierror"
@@ -60,6 +61,13 @@ func (h *flowExecutionHandler) HandleFlowExecutionRequest(w http.ResponseWriter,
6061
// them available to the flow service, which selects the handle once the flow is known.
6162
ctx := session.WithInbound(r.Context(), h.ssoTransport.Read(r))
6263

64+
// Carry the current step's request headers and query params so flow executors can publish them
65+
// via {{request(flow.*)}} placeholders. Credential-bearing headers are stripped at this boundary.
66+
ctx = withCurrentRequest(ctx, &providers.InitiatorRequest{
67+
Headers: sysutils.FilterSensitiveHeaders(r.Header),
68+
QueryParams: r.URL.Query(),
69+
})
70+
6371
var flowStep *FlowStep
6472
var flowErr *tidcommon.ServiceError
6573
if flowID != "" {

backend/internal/flow/flowexec/handler_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,40 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_PropagatesInboundSSOCo
191191
s.Equal("inbound-handle", gotInbound.HandleFor("flow-1"))
192192
}
193193

194+
// TestHandleFlowExecutionRequest_CapturesCurrentRequest verifies the current step's request headers
195+
// and query params are captured onto the service context, with credential headers stripped.
196+
func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_CapturesCurrentRequest() {
197+
t := s.T()
198+
mockSvc := NewFlowExecServiceInterfaceMock(t)
199+
200+
var gotRequest *providers.InitiatorRequest
201+
mockSvc.EXPECT().Execute(mock.Anything, mock.Anything, mock.Anything, mock.Anything,
202+
mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
203+
Run(func(ctx context.Context, _ string, _ string, _ string, _ bool, _ string,
204+
_ map[string]string, _ string, _ string, _ string) {
205+
gotRequest = currentRequestFrom(ctx)
206+
}).
207+
Return(&FlowStep{ExecutionID: "exec-1", Status: providers.FlowStatusIncomplete},
208+
(*tidcommon.ServiceError)(nil))
209+
210+
h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(false), 0)
211+
req := httptest.NewRequest(http.MethodPost, "/flow/execute?utm_source=newsletter",
212+
bytes.NewBufferString(testFlowExecRequestBody))
213+
req.Header.Set("Content-Type", "application/json")
214+
req.Header.Set("User-Agent", "curl/8.0")
215+
req.Header.Set("Authorization", "Bearer secret")
216+
w := httptest.NewRecorder()
217+
218+
h.HandleFlowExecutionRequest(w, req)
219+
220+
s.Equal(http.StatusOK, w.Code)
221+
s.Require().NotNil(gotRequest, "current request must be captured onto the service context")
222+
s.Equal("curl/8.0", http.Header(gotRequest.Headers).Get("User-Agent"))
223+
s.Empty(http.Header(gotRequest.Headers).Get("Authorization"), "credential headers must be stripped")
224+
s.Require().Contains(gotRequest.QueryParams, "utm_source")
225+
s.Equal("newsletter", gotRequest.QueryParams["utm_source"][0])
226+
}
227+
194228
// TestHandleFlowExecutionRequest_WritesSSOHandleCookie verifies a minted handle is emitted as the
195229
// per-flow cookie with the configured TTL and secure/http-only transport settings.
196230
func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_WritesSSOHandleCookie() {

backend/internal/flow/flowexec/model.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ type frame struct {
4141
type EngineContext struct {
4242
Context context.Context
4343
initiatorRequest *providers.InitiatorRequest
44+
// currentRequest carries the HTTP request driving the current flow step. It is transient:
45+
// supplied on each execution from the request context and never persisted with the flow context.
46+
currentRequest *providers.InitiatorRequest
4447

4548
ExecutionID string
4649
FlowType providers.FlowType
@@ -103,6 +106,16 @@ func (ec *EngineContext) SetInitiatorRequest(req *providers.InitiatorRequest) {
103106
ec.initiatorRequest = req
104107
}
105108

109+
// GetCurrentRequest returns the HTTP request that drives the current flow step.
110+
func (ec *EngineContext) GetCurrentRequest() *providers.InitiatorRequest {
111+
return ec.currentRequest
112+
}
113+
114+
// SetCurrentRequest sets the HTTP request that drives the current flow step.
115+
func (ec *EngineContext) SetCurrentRequest(req *providers.InitiatorRequest) {
116+
ec.currentRequest = req
117+
}
118+
106119
// mergeRuntimeData merges the given data into RuntimeData.
107120
func (ec *EngineContext) mergeRuntimeData(data map[string]string) {
108121
if ec.RuntimeData == nil {

0 commit comments

Comments
 (0)