Skip to content

Commit 690b12f

Browse files
feat(license): inject X-Axonflow-Client header on every governed request (#161)
Per ADR-050 §4, every governed client must set `X-Axonflow-Client: <client-id>/<version>` on every request to the agent so the agent can derive request scope (sdk) and validate it against the token's aud.scope via HasScope(). This PR: - Adds AxonFlowConfig.getClientHeader() which returns "sdk-java/<SDK_VERSION>". Sourced from the bundled SDK_VERSION; no env / config override (the consumer doesn't get to spoof its own client identity to the agent). - Stamps the header alongside User-Agent at every Request.Builder site in AxonFlow.java that already sets User-Agent (5 sites). - Stamps the header inside addAuthHeaders() so any builder that calls addAuthHeaders without explicit User-Agent (e.g. the providers listing path at AxonFlow.java:1922) also ships it. OkHttp's Builder.header() replaces, so the small overlap with the explicit per-site stamps just resets the same value. Test coverage: - ClientHeaderTest asserts X-Axonflow-Client is forwarded on proxyLLMCall and pins the agent-parseable "sdk-java/<semver>" format. - Full Maven test suite stays green: 1228 tests, 0 failures. Signed-off-by: Saurabh Jain <saurabhjain1592@gmail.com>
1 parent f967a46 commit 690b12f

3 files changed

Lines changed: 107 additions & 0 deletions

File tree

src/main/java/com/getaxonflow/sdk/AxonFlow.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3365,6 +3365,7 @@ public void streamExecutionStatus(
33653365
new Request.Builder()
33663366
.url(url)
33673367
.header("User-Agent", config.getUserAgent())
3368+
.header("X-Axonflow-Client", config.getClientHeader())
33683369
.header("Accept", "text/event-stream")
33693370
.get();
33703371

@@ -3573,6 +3574,7 @@ private Request buildRequest(String method, String path, Object body) {
35733574
new Request.Builder()
35743575
.url(url)
35753576
.header("User-Agent", config.getUserAgent())
3577+
.header("X-Axonflow-Client", config.getClientHeader())
35763578
.header("Accept", "application/json");
35773579

35783580
// Add authentication headers
@@ -3624,6 +3626,7 @@ private Request buildPatchRequest(String path, Object body) {
36243626
new Request.Builder()
36253627
.url(url)
36263628
.header("User-Agent", config.getUserAgent())
3629+
.header("X-Axonflow-Client", config.getClientHeader())
36273630
.header("Accept", "application/json");
36283631

36293632
addAuthHeaders(builder);
@@ -3763,6 +3766,10 @@ private void addAuthHeaders(Request.Builder builder) {
37633766
String encoded =
37643767
Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
37653768
builder.header("Authorization", "Basic " + encoded);
3769+
// ADR-050 §4: every governed request to the agent carries X-Axonflow-Client
3770+
// so the agent can derive request scope (sdk) and validate it against the
3771+
// token's aud.scope via HasScope(). Sourced from SDK_VERSION; no env override.
3772+
builder.header("X-Axonflow-Client", config.getClientHeader());
37663773
}
37673774

37683775
/**
@@ -4372,6 +4379,7 @@ private Request buildOrchestratorRequest(String method, String path, Object body
43724379
new Request.Builder()
43734380
.url(url)
43744381
.header("User-Agent", config.getUserAgent())
4382+
.header("X-Axonflow-Client", config.getClientHeader())
43754383
.header("Accept", "application/json");
43764384

43774385
addAuthHeaders(builder);
@@ -4440,6 +4448,7 @@ private Request buildPortalRequest(String method, String path, Object body) {
44404448
new Request.Builder()
44414449
.url(url)
44424450
.header("User-Agent", config.getUserAgent())
4451+
.header("X-Axonflow-Client", config.getClientHeader())
44434452
.header("Accept", "application/json");
44444453

44454454
addPortalSessionCookie(builder);

src/main/java/com/getaxonflow/sdk/AxonFlowConfig.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,20 @@ public String getUserAgent() {
292292
return userAgent;
293293
}
294294

295+
/**
296+
* Returns the X-Axonflow-Client header value identifying this SDK + version.
297+
*
298+
* <p>Per ADR-050 §4, every governed request to the agent carries this header so the agent can
299+
* derive request scope (sdk) and validate it against the token's aud.scope via HasScope().
300+
* Sourced from the bundled {@link #SDK_VERSION}; there is intentionally no env / config
301+
* override (the consumer doesn't get to spoof its own client identity to the agent).
302+
*
303+
* @return the agent-parseable {@code "sdk-java/<semver>"} client header value
304+
*/
305+
public String getClientHeader() {
306+
return "sdk-java/" + SDK_VERSION;
307+
}
308+
295309
/**
296310
* Returns the telemetry config override.
297311
*
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright 2025 AxonFlow
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.getaxonflow.sdk;
17+
18+
import static com.github.tomakehurst.wiremock.client.WireMock.*;
19+
import static org.assertj.core.api.Assertions.*;
20+
21+
import com.getaxonflow.sdk.types.*;
22+
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
23+
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
24+
import org.junit.jupiter.api.DisplayName;
25+
import org.junit.jupiter.api.Test;
26+
27+
/**
28+
* X-Axonflow-Client header injection — ADR-050 §4.
29+
*
30+
* <p>Asserts every governed request forwards {@code X-Axonflow-Client:
31+
* sdk-java/<SDK_VERSION>} so the agent can derive request scope (sdk) and validate against the
32+
* token's aud.scope via HasScope().
33+
*
34+
* <p>Header value is sourced from the bundled {@link AxonFlowConfig#SDK_VERSION}; the consumer
35+
* cannot spoof its own client identity through config (intentional — honest-99% header injection
36+
* per ADR-050 §4).
37+
*/
38+
@WireMockTest
39+
@DisplayName("X-Axonflow-Client header injection")
40+
class ClientHeaderTest {
41+
42+
private static final String EXPECTED_CLIENT = "sdk-java/" + AxonFlowConfig.SDK_VERSION;
43+
44+
@Test
45+
@DisplayName("should send X-Axonflow-Client on proxyLLMCall")
46+
void shouldSendClientHeaderOnProxy(WireMockRuntimeInfo wmRuntimeInfo) {
47+
stubFor(
48+
post(urlEqualTo("/api/request"))
49+
.willReturn(
50+
aResponse()
51+
.withStatus(200)
52+
.withHeader("Content-Type", "application/json")
53+
.withBody(
54+
"{" + "\"success\": true," + "\"data\": {\"answer\": \"ok\"}" + "}")));
55+
56+
AxonFlow client =
57+
AxonFlow.create(
58+
AxonFlowConfig.builder()
59+
.agentUrl(wmRuntimeInfo.getHttpBaseUrl())
60+
.clientId("test-client")
61+
.clientSecret("test-secret")
62+
.build());
63+
64+
client.proxyLLMCall(ClientRequest.builder().userToken("").query("ping").build());
65+
66+
verify(
67+
postRequestedFor(urlEqualTo("/api/request"))
68+
.withHeader("X-Axonflow-Client", equalTo(EXPECTED_CLIENT)));
69+
}
70+
71+
@Test
72+
@DisplayName("getClientHeader returns sdk-java/<semver>")
73+
void getClientHeaderShouldMatchExpectedFormat() {
74+
AxonFlowConfig config =
75+
AxonFlowConfig.builder().agentUrl("http://localhost:8080").build();
76+
77+
String header = config.getClientHeader();
78+
assertThat(header).startsWith("sdk-java/");
79+
// Sanity: agent's deriveScopeFromClientHeader splits on '/' and maps
80+
// "sdk-*" prefixes to scope=sdk. Lock down the shape.
81+
assertThat(header.split("/")).hasSize(2);
82+
assertThat(header.split("/")[0]).isEqualTo("sdk-java");
83+
}
84+
}

0 commit comments

Comments
 (0)