Skip to content

Commit 5f902b5

Browse files
committed
initial commit
1 parent fa0265f commit 5f902b5

5 files changed

Lines changed: 292 additions & 22 deletions

File tree

‎azurefunctions/src/main/java/com/microsoft/durabletask/azurefunctions/DurableClientContext.java‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.net.URLEncoder;
2626
import java.nio.charset.StandardCharsets;
2727
import java.time.Duration;
28+
import java.util.Map;
2829
import java.util.concurrent.TimeoutException;
2930

3031
/**
@@ -35,6 +36,7 @@ public class DurableClientContext {
3536
private String rpcBaseUrl;
3637
private String taskHubName;
3738
private String requiredQueryStringParameters;
39+
private Map<String, String> requiredHeaders;
3840
private DurableTaskClient client;
3941

4042
/**
@@ -120,11 +122,12 @@ public HttpResponseMessage createCheckStatusResponse(HttpRequestMessage<?> reque
120122
// More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded.
121123
// One potential workaround is to set ASPNETCORE_FORWARDEDHEADERS_ENABLED to true.
122124

123-
// Construct the response as an HTTP 202 with a JSON object payload
125+
HttpManagementPayload payload = this.getClientResponseLinks(request, instanceId);
124126
return request.createResponseBuilder(HttpStatus.ACCEPTED)
125-
.header("Location", this.getInstanceStatusURL(request, instanceId) + "?" + this.requiredQueryStringParameters)
127+
.header("Location", payload.getStatusQueryGetUri())
126128
.header("Content-Type", "application/json")
127-
.body(this.getClientResponseLinks(request, instanceId))
129+
.header("Cache-Control", "no-store")
130+
.body(payload)
128131
.build();
129132
}
130133

@@ -215,7 +218,7 @@ public CleanEntityStorageResult cleanEntityStorage(CleanEntityStorageRequest req
215218

216219
private HttpManagementPayload getClientResponseLinks(HttpRequestMessage<?> request, String instanceId) {
217220
String instanceStatusURL = this.getInstanceStatusURL(request, instanceId);
218-
return new HttpManagementPayload(instanceId, instanceStatusURL, this.requiredQueryStringParameters);
221+
return new HttpManagementPayload(instanceId, instanceStatusURL, this.requiredQueryStringParameters, this.requiredHeaders);
219222
}
220223

221224
private String getInstanceStatusURL(HttpRequestMessage<?> request, String instanceId) {

‎azurefunctions/src/main/java/com/microsoft/durabletask/azurefunctions/HttpManagementPayload.java‎

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66

77
package com.microsoft.durabletask.azurefunctions;
88

9+
import java.io.UnsupportedEncodingException;
10+
import java.net.URLDecoder;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.ArrayList;
13+
import java.util.Collections;
14+
import java.util.LinkedHashMap;
15+
import java.util.List;
16+
import java.util.Locale;
17+
import java.util.Map;
18+
919
/**
1020
* The class that holds the webhook URLs to manage orchestration instances.
1121
*/
@@ -19,6 +29,7 @@ public class HttpManagementPayload {
1929
private final String terminatePostUri;
2030
private final String resumePostUri;
2131
private final String suspendPostUri;
32+
private final Map<String, String> requiredHeaders;
2233

2334
/**
2435
* Creates a {@link HttpManagementPayload} to manage orchestration instances
@@ -31,15 +42,81 @@ public HttpManagementPayload(
3142
String instanceId,
3243
String instanceStatusURL,
3344
String requiredQueryStringParameters) {
45+
this(instanceId, instanceStatusURL, requiredQueryStringParameters, Collections.emptyMap());
46+
}
47+
48+
/**
49+
* Creates management URLs with authentication supplied separately from the query string.
50+
*
51+
* @param instanceId The ID of the orchestration instance
52+
* @param instanceStatusURL The base webhook URL of the orchestration instance
53+
* @param requiredQueryStringParameters The non-secret query parameters to include with requests
54+
* @param requiredHeaders The headers callers must send when using the management URLs
55+
*/
56+
public HttpManagementPayload(
57+
String instanceId,
58+
String instanceStatusURL,
59+
String requiredQueryStringParameters,
60+
Map<String, String> requiredHeaders) {
61+
Map<String, String> headers = new LinkedHashMap<>();
62+
if (requiredHeaders != null) {
63+
requiredHeaders.forEach((name, value) -> headers.put(name.toLowerCase(Locale.ROOT), value));
64+
}
65+
66+
List<String> parameters = new ArrayList<>();
67+
String query = requiredQueryStringParameters == null ? "" : requiredQueryStringParameters;
68+
if (query.startsWith("?")) {
69+
query = query.substring(1);
70+
}
71+
72+
// Older hosts include the system key in the binding query string.
73+
for (String parameter : query.split("&")) {
74+
if (parameter.isEmpty()) {
75+
continue;
76+
}
77+
78+
int separator = parameter.indexOf('=');
79+
String name = separator < 0 ? parameter : parameter.substring(0, separator);
80+
if (decode(name).equalsIgnoreCase("code")) {
81+
if (separator >= 0) {
82+
headers.putIfAbsent("x-functions-key", decode(parameter.substring(separator + 1)));
83+
}
84+
} else {
85+
parameters.add(parameter);
86+
}
87+
}
88+
89+
String queryString = String.join("&", parameters);
90+
String querySuffix = queryString.isEmpty() ? "" : "?" + queryString;
91+
String reasonSuffix = "?reason={text}" + (queryString.isEmpty() ? "" : "&" + queryString);
3492
this.id = instanceId;
35-
this.purgeHistoryDeleteUri = instanceStatusURL + "?" + requiredQueryStringParameters;
36-
this.restartPostUri = instanceStatusURL + "/restart?" + requiredQueryStringParameters;
37-
this.rewindPostUri = instanceStatusURL + "/rewind?reason={text}&" + requiredQueryStringParameters;
38-
this.sendEventPostUri = instanceStatusURL + "/raiseEvent/{eventName}?" + requiredQueryStringParameters;
39-
this.statusQueryGetUri = instanceStatusURL + "?" + requiredQueryStringParameters;
40-
this.terminatePostUri = instanceStatusURL + "/terminate?reason={text}&" + requiredQueryStringParameters;
41-
this.resumePostUri = instanceStatusURL + "/resume?reason={text}&" + requiredQueryStringParameters;
42-
this.suspendPostUri = instanceStatusURL + "/suspend?reason={text}&" + requiredQueryStringParameters;
93+
this.requiredHeaders = Collections.unmodifiableMap(headers);
94+
this.purgeHistoryDeleteUri = instanceStatusURL + querySuffix;
95+
this.restartPostUri = instanceStatusURL + "/restart" + querySuffix;
96+
this.rewindPostUri = instanceStatusURL + "/rewind" + reasonSuffix;
97+
this.sendEventPostUri = instanceStatusURL + "/raiseEvent/{eventName}" + querySuffix;
98+
this.statusQueryGetUri = instanceStatusURL + querySuffix;
99+
this.terminatePostUri = instanceStatusURL + "/terminate" + reasonSuffix;
100+
this.resumePostUri = instanceStatusURL + "/resume" + reasonSuffix;
101+
this.suspendPostUri = instanceStatusURL + "/suspend" + reasonSuffix;
102+
}
103+
104+
/**
105+
* Gets the headers callers must send when using the management URLs.
106+
* Treat these values as secrets; do not log them or append them to URLs.
107+
*
108+
* @return An immutable map of required HTTP request headers.
109+
*/
110+
public Map<String, String> getRequiredHeaders() {
111+
return this.requiredHeaders;
112+
}
113+
114+
private static String decode(String value) {
115+
try {
116+
return URLDecoder.decode(value, StandardCharsets.UTF_8.name());
117+
} catch (UnsupportedEncodingException ex) {
118+
throw new IllegalStateException("UTF-8 encoding is unavailable.", ex);
119+
}
43120
}
44121

45122
/**
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.microsoft.durabletask.azurefunctions;
5+
6+
import com.google.gson.Gson;
7+
import com.google.gson.JsonObject;
8+
import com.microsoft.azure.functions.HttpRequestMessage;
9+
import com.microsoft.azure.functions.HttpResponseMessage;
10+
import com.microsoft.azure.functions.HttpStatus;
11+
import org.junit.jupiter.api.Test;
12+
import org.mockito.ArgumentCaptor;
13+
14+
import java.net.URI;
15+
16+
import static org.junit.jupiter.api.Assertions.*;
17+
import static org.mockito.Mockito.*;
18+
19+
class DurableClientContextTest {
20+
private final Gson gson = new Gson();
21+
22+
@Test
23+
void bindingHeadersAreIncludedInPayloadAndLocationIsKeyFree() {
24+
String binding = "{\"rpcBaseUrl\":\"http://127.0.0.1:12345\","
25+
+ "\"requiredQueryStringParameters\":\"taskHub=hub&connection=storage\","
26+
+ "\"requiredHeaders\":{\"x-functions-key\":\"synthetic-key\"}}";
27+
assertCheckStatusResponse(binding, "synthetic-key",
28+
"?taskHub=hub&connection=storage");
29+
}
30+
31+
@Test
32+
void legacyBindingQueryKeyIsConvertedToHeaderMetadata() {
33+
String binding = "{\"requiredQueryStringParameters\":\"CoDe=synthetic%2Bkey&site=example\"}";
34+
assertCheckStatusResponse(binding, "synthetic+key", "?site=example");
35+
}
36+
37+
@Test
38+
void bindingCredentialsRemainScopedToEachContext() {
39+
DurableClientContext first = this.gson.fromJson(
40+
"{\"requiredHeaders\":{\"x-functions-key\":\"first\"}}", DurableClientContext.class);
41+
DurableClientContext second = this.gson.fromJson(
42+
"{\"requiredHeaders\":{\"x-functions-key\":\"second\"}}", DurableClientContext.class);
43+
HttpRequestMessage<?> request = request();
44+
45+
assertEquals("first", first.createHttpManagementPayload(request, "instance")
46+
.getRequiredHeaders().get("x-functions-key"));
47+
assertEquals("second", second.createHttpManagementPayload(request, "instance")
48+
.getRequiredHeaders().get("x-functions-key"));
49+
}
50+
51+
private void assertCheckStatusResponse(String binding, String key, String query) {
52+
DurableClientContext context = this.gson.fromJson(binding, DurableClientContext.class);
53+
HttpRequestMessage<?> request = request();
54+
HttpResponseMessage.Builder builder = mock(HttpResponseMessage.Builder.class, RETURNS_SELF);
55+
HttpResponseMessage response = mock(HttpResponseMessage.class);
56+
when(request.createResponseBuilder(HttpStatus.ACCEPTED)).thenReturn(builder);
57+
when(builder.build()).thenReturn(response);
58+
59+
assertSame(response, context.createCheckStatusResponse(request, "instance"));
60+
61+
ArgumentCaptor<Object> body = ArgumentCaptor.forClass(Object.class);
62+
verify(builder).body(body.capture());
63+
HttpManagementPayload payload = (HttpManagementPayload) body.getValue();
64+
String expectedUrl = "https://example.com/runtime/webhooks/durabletask/instances/instance" + query;
65+
assertEquals(expectedUrl, payload.getStatusQueryGetUri());
66+
verify(builder).header("Location", expectedUrl);
67+
verify(builder).header("Cache-Control", "no-store");
68+
JsonObject json = this.gson.toJsonTree(payload).getAsJsonObject();
69+
assertEquals(key, json.getAsJsonObject("requiredHeaders").get("x-functions-key").getAsString());
70+
assertFalse(payload.getStatusQueryGetUri().contains("code="));
71+
assertFalse(payload.getStatusQueryGetUri().contains("synthetic"));
72+
}
73+
74+
private static HttpRequestMessage<?> request() {
75+
HttpRequestMessage<?> request = mock(HttpRequestMessage.class);
76+
when(request.getUri()).thenReturn(URI.create("https://example.com/api/start?code=starter-key"));
77+
return request;
78+
}
79+
}

‎azurefunctions/src/test/java/com/microsoft/durabletask/azurefunctions/HttpManagementPayloadTest.java‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
import org.junit.jupiter.api.DisplayName;
44
import org.junit.jupiter.api.Test;
5+
import org.junit.jupiter.params.ParameterizedTest;
6+
import org.junit.jupiter.params.provider.ValueSource;
7+
8+
import java.util.Arrays;
9+
import java.util.Collections;
10+
import java.util.HashMap;
11+
import java.util.Map;
512

613
import static org.junit.jupiter.api.Assertions.*;
714

@@ -12,7 +19,7 @@ public class HttpManagementPayloadTest {
1219

1320
private static final String INSTANCE_ID = "test-instance-id";
1421
private static final String INSTANCE_STATUS_URL = "http://localhost:7071/runtime/webhooks/durabletask/instances/test-instance-id";
15-
private static final String QUERY_STRING = "code=abc123";
22+
private static final String QUERY_STRING = "taskHub=hub&connection=storage";
1623

1724
private HttpManagementPayload createPayload() {
1825
return new HttpManagementPayload(INSTANCE_ID, INSTANCE_STATUS_URL, QUERY_STRING);
@@ -80,4 +87,49 @@ public void getRewindPostUri_ReturnsCorrectUrl() {
8087
HttpManagementPayload payload = createPayload();
8188
assertEquals(INSTANCE_STATUS_URL + "/rewind?reason={text}&" + QUERY_STRING, payload.getRewindPostUri());
8289
}
90+
91+
@ParameterizedTest
92+
@ValueSource(strings = {
93+
"code=synthetic%2Bkey%2F%3D&site=value%2Bone",
94+
"site=value%2Bone&CoDe=synthetic%2Bkey%2F%3D",
95+
"%63ode=synthetic%2Bkey%2F%3D&site=value%2Bone&CODE=duplicate"
96+
})
97+
void legacyQueryKeyIsMovedToRequiredHeaders(String query) {
98+
HttpManagementPayload payload = new HttpManagementPayload(INSTANCE_ID, INSTANCE_STATUS_URL, query);
99+
100+
assertEquals("synthetic+key/=", payload.getRequiredHeaders().get("x-functions-key"));
101+
assertEquals(INSTANCE_STATUS_URL + "?site=value%2Bone", payload.getStatusQueryGetUri());
102+
for (String url : Arrays.asList(
103+
payload.getStatusQueryGetUri(), payload.getSendEventPostUri(), payload.getTerminatePostUri(),
104+
payload.getRewindPostUri(), payload.getPurgeHistoryDeleteUri(), payload.getRestartPostUri(),
105+
payload.getSuspendPostUri(), payload.getResumePostUri())) {
106+
assertTrue(url.contains("site=value%2Bone"));
107+
assertFalse(url.toLowerCase().contains("code="));
108+
assertFalse(url.contains("synthetic"));
109+
assertFalse(url.contains("duplicate"));
110+
}
111+
}
112+
113+
@Test
114+
void explicitHeadersOverrideLegacyKeyAndAreDefensivelyCopied() {
115+
Map<String, String> headers = new HashMap<>();
116+
headers.put("X-Functions-Key", "current-key");
117+
HttpManagementPayload payload = new HttpManagementPayload(
118+
INSTANCE_ID, INSTANCE_STATUS_URL, "code=old-key&" + QUERY_STRING, headers);
119+
headers.put("X-Functions-Key", "changed");
120+
121+
assertEquals(Collections.singletonMap("x-functions-key", "current-key"), payload.getRequiredHeaders());
122+
assertThrows(UnsupportedOperationException.class, () -> payload.getRequiredHeaders().put("extra", "value"));
123+
assertEquals(INSTANCE_STATUS_URL + "?" + QUERY_STRING, payload.getStatusQueryGetUri());
124+
}
125+
126+
@Test
127+
void missingOrEmptyQueryDoesNotAddNullOrTrailingSeparators() {
128+
for (String query : new String[] { null, "", "code=synthetic-key" }) {
129+
HttpManagementPayload payload = new HttpManagementPayload(INSTANCE_ID, INSTANCE_STATUS_URL, query);
130+
assertEquals(INSTANCE_STATUS_URL, payload.getStatusQueryGetUri());
131+
assertEquals(INSTANCE_STATUS_URL + "/terminate?reason={text}", payload.getTerminatePostUri());
132+
}
133+
assertTrue(createPayload().getRequiredHeaders().isEmpty());
134+
}
83135
}

0 commit comments

Comments
 (0)