fix(esg): structured JSON AuthConfiguration (matches entity doc + UI placeholders) with legacy colon fallback — keys containing ':' no longer misparse
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -35,6 +35,12 @@ Each external system definition includes:
|
|||||||
- **Authentication**: One of:
|
- **Authentication**: One of:
|
||||||
- **API Key**: Header name (e.g., `X-API-Key`) and key value.
|
- **API Key**: Header name (e.g., `X-API-Key`) and key value.
|
||||||
- **Basic Auth**: Username and password.
|
- **Basic Auth**: Username and password.
|
||||||
|
|
||||||
|
The `AuthConfiguration` value is stored as **structured JSON** (the form the entity doc-comment and the Central UI placeholders promise):
|
||||||
|
- **API Key**: `{"header": "X-API-Key", "key": "<key>"}`. `header` is optional and defaults to `X-API-Key`; `key` is required. A key value containing `:` is preserved verbatim (JSON parsing does not split on `:`). Property names are matched case-insensitively.
|
||||||
|
- **Basic Auth**: `{"username": "<user>", "password": "<pass>"}`. Both fields are Base64-encoded as `username:password` for the `Authorization: Basic` header; a password containing `:` is preserved.
|
||||||
|
|
||||||
|
**Legacy colon form (deprecated):** a value that does not start with `{` is parsed with the historical colon-split rules for backward compatibility — API Key as `HeaderName:KeyValue` (or a bare `KeyValue` using the default `X-API-Key` header), Basic Auth as `username:password`. This form cannot represent keys or passwords that contain `:` and is retained only so existing rows keep working; new definitions should use the JSON form. Malformed JSON (a value that starts with `{` but does not parse, or yields no usable `key`/`username`) is **not** an exception — the request is sent without the auth header and a warning is logged (the configuration value itself is never logged).
|
||||||
- **Timeout**: Per-system timeout for all method calls (e.g., 30 seconds). Applies to the HTTP request round-trip. `0` (the default) means "unset" — the gateway's configured `DefaultHttpTimeout` applies instead. Set via the Central UI External System form's "Timeout (seconds)" field; ships to sites in the deployment artifact like the other definition fields.
|
- **Timeout**: Per-system timeout for all method calls (e.g., 30 seconds). Applies to the HTTP request round-trip. `0` (the default) means "unset" — the gateway's configured `DefaultHttpTimeout` applies instead. Set via the Central UI External System form's "Timeout (seconds)" field; ships to sites in the deployment artifact like the other definition fields.
|
||||||
- **Retry Settings**: Max retry count, fixed time between retries (used by Store-and-Forward Engine for transient failures only).
|
- **Retry Settings**: Max retry count, fixed time between retries (used by Store-and-Forward Engine for transient failures only).
|
||||||
- **Method Definitions**: List of available API methods, each with:
|
- **Method Definitions**: List of available API methods, each with:
|
||||||
|
|||||||
@@ -591,10 +591,40 @@ public class ExternalSystemClient : IExternalSystemClient
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A config whose trimmed value starts with '{' is the structured JSON form
|
||||||
|
// promised by the entity doc-comment and the Central UI placeholders. Parse
|
||||||
|
// it first; only a non-JSON value falls through to the legacy colon-split.
|
||||||
|
var isJson = system.AuthConfiguration.TrimStart().StartsWith('{');
|
||||||
|
Dictionary<string, string?>? jsonFields = null;
|
||||||
|
var jsonParsed = isJson && TryParseJsonAuth(system.AuthConfiguration, out jsonFields);
|
||||||
|
|
||||||
switch (authType)
|
switch (authType)
|
||||||
{
|
{
|
||||||
case "apikey":
|
case "apikey":
|
||||||
// Auth config format: "HeaderName:KeyValue" or just "KeyValue" (default header: X-API-Key)
|
// JSON form: {"header"?: string, "key": string} (default header X-API-Key).
|
||||||
|
if (isJson)
|
||||||
|
{
|
||||||
|
if (jsonParsed &&
|
||||||
|
jsonFields!.TryGetValue("key", out var jsonKey) &&
|
||||||
|
!string.IsNullOrEmpty(jsonKey))
|
||||||
|
{
|
||||||
|
var header = jsonFields.TryGetValue("header", out var h) && !string.IsNullOrEmpty(h)
|
||||||
|
? h!
|
||||||
|
: "X-API-Key";
|
||||||
|
request.Headers.TryAddWithoutValidation(header, jsonKey);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Malformed JSON, or JSON with no usable "key": send without a
|
||||||
|
// header and warn (the value is never logged). Never throw.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"ApplyAuth: External system '{System}' AuthType 'apikey' AuthConfiguration is malformed JSON (expected {{\"header\"?:...,\"key\":...}}); request will be sent without an auth header.",
|
||||||
|
system.Name);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy config format: "HeaderName:KeyValue" or just "KeyValue" (default header: X-API-Key)
|
||||||
var parts = system.AuthConfiguration.Split(':', 2);
|
var parts = system.AuthConfiguration.Split(':', 2);
|
||||||
if (parts.Length == 2)
|
if (parts.Length == 2)
|
||||||
{
|
{
|
||||||
@@ -607,7 +637,28 @@ public class ExternalSystemClient : IExternalSystemClient
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "basic":
|
case "basic":
|
||||||
// Auth config format: "username:password"
|
// JSON form: {"username": string, "password": string}.
|
||||||
|
if (isJson)
|
||||||
|
{
|
||||||
|
if (jsonParsed &&
|
||||||
|
jsonFields!.TryGetValue("username", out var jsonUser) &&
|
||||||
|
!string.IsNullOrEmpty(jsonUser))
|
||||||
|
{
|
||||||
|
var password = jsonFields.TryGetValue("password", out var p) ? p ?? string.Empty : string.Empty;
|
||||||
|
var jsonEncoded = Convert.ToBase64String(
|
||||||
|
Encoding.UTF8.GetBytes($"{jsonUser}:{password}"));
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", jsonEncoded);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"ApplyAuth: External system '{System}' AuthType 'basic' AuthConfiguration is malformed JSON (expected {{\"username\":...,\"password\":...}}); request will be sent without an Authorization header.",
|
||||||
|
system.Name);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy config format: "username:password"
|
||||||
var basicParts = system.AuthConfiguration.Split(':', 2);
|
var basicParts = system.AuthConfiguration.Split(':', 2);
|
||||||
if (basicParts.Length == 2)
|
if (basicParts.Length == 2)
|
||||||
{
|
{
|
||||||
@@ -639,6 +690,37 @@ public class ExternalSystemClient : IExternalSystemClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a structured JSON <c>AuthConfiguration</c> into a case-insensitive
|
||||||
|
/// field map (e.g. <c>{"header":"X-Auth","key":"..."}</c> or
|
||||||
|
/// <c>{"username":"u","password":"p"}</c>). Returns <c>false</c> on malformed
|
||||||
|
/// JSON (<see cref="JsonException"/>) instead of throwing, so the caller can
|
||||||
|
/// fall through to the "send without header" warning path. Values are never
|
||||||
|
/// logged.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryParseJsonAuth(string config, out Dictionary<string, string?> fields)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fields = JsonSerializer.Deserialize<Dictionary<string, string?>>(
|
||||||
|
config,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||||
|
?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Normalize to case-insensitive so "Header"/"header" both resolve.
|
||||||
|
if (!ReferenceEquals(fields.Comparer, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
fields = new Dictionary<string, string?>(fields, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
fields = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(ExternalSystemDefinition? system, ExternalSystemMethod? method)> ResolveSystemAndMethodAsync(
|
private async Task<(ExternalSystemDefinition? system, ExternalSystemMethod? method)> ResolveSystemAndMethodAsync(
|
||||||
string systemName,
|
string systemName,
|
||||||
string methodName,
|
string methodName,
|
||||||
|
|||||||
@@ -828,6 +828,105 @@ public class ExternalSystemClientTests
|
|||||||
Assert.Equal("alice:s3cret", decoded);
|
Assert.Equal("alice:s3cret", decoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApiKeyAuth_JsonConfig_SetsConfiguredHeaderAndKeepsColons()
|
||||||
|
{
|
||||||
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthConfiguration = "{\"header\":\"X-Auth\",\"key\":\"ab:cd:ef\"}",
|
||||||
|
};
|
||||||
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
|
StubResolution(system, method);
|
||||||
|
|
||||||
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
||||||
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
||||||
|
|
||||||
|
var client = new ExternalSystemClient(
|
||||||
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
||||||
|
|
||||||
|
await client.CallAsync("TestAPI", "getData");
|
||||||
|
|
||||||
|
Assert.True(handler.LastHeaders!.TryGetValues("X-Auth", out var values));
|
||||||
|
Assert.Equal("ab:cd:ef", values!.Single());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApiKeyAuth_JsonConfig_DefaultHeader()
|
||||||
|
{
|
||||||
|
// The UI-placeholder shape ({"key":"xyz"}) — silently misparsed by the
|
||||||
|
// legacy colon-split before Task 15 (it produced header name '{"key').
|
||||||
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthConfiguration = "{\"key\":\"xyz\"}",
|
||||||
|
};
|
||||||
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
|
StubResolution(system, method);
|
||||||
|
|
||||||
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
||||||
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
||||||
|
|
||||||
|
var client = new ExternalSystemClient(
|
||||||
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
||||||
|
|
||||||
|
await client.CallAsync("TestAPI", "getData");
|
||||||
|
|
||||||
|
Assert.True(handler.LastHeaders!.TryGetValues("X-API-Key", out var values));
|
||||||
|
Assert.Equal("xyz", values!.Single());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BasicAuth_JsonConfig_EncodesUsernamePassword()
|
||||||
|
{
|
||||||
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "basic")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthConfiguration = "{\"username\":\"u\",\"password\":\"p:w\"}",
|
||||||
|
};
|
||||||
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
|
StubResolution(system, method);
|
||||||
|
|
||||||
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
||||||
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
||||||
|
|
||||||
|
var client = new ExternalSystemClient(
|
||||||
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
||||||
|
|
||||||
|
await client.CallAsync("TestAPI", "getData");
|
||||||
|
|
||||||
|
var auth = handler.LastHeaders!.Authorization;
|
||||||
|
Assert.NotNull(auth);
|
||||||
|
Assert.Equal("Basic", auth!.Scheme);
|
||||||
|
var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(auth.Parameter!));
|
||||||
|
Assert.Equal("u:p:w", decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApiKeyAuth_LegacyColonConfig_StillWorks()
|
||||||
|
{
|
||||||
|
// Backward compatibility: the legacy "HeaderName:KeyValue" colon form must
|
||||||
|
// keep working for existing rows.
|
||||||
|
var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "apikey")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
AuthConfiguration = "X-Custom:secret",
|
||||||
|
};
|
||||||
|
var method = new ExternalSystemMethod("getData", "GET", "/data") { Id = 1, ExternalSystemDefinitionId = 1 };
|
||||||
|
StubResolution(system, method);
|
||||||
|
|
||||||
|
var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}");
|
||||||
|
_httpClientFactory.CreateClient(Arg.Any<string>()).Returns(new HttpClient(handler));
|
||||||
|
|
||||||
|
var client = new ExternalSystemClient(
|
||||||
|
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance);
|
||||||
|
|
||||||
|
await client.CallAsync("TestAPI", "getData");
|
||||||
|
|
||||||
|
Assert.True(handler.LastHeaders!.TryGetValues("X-Custom", out var values));
|
||||||
|
Assert.Equal("secret", values!.Single());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Call_ConnectionError_IsClassifiedAsTransient()
|
public async Task Call_ConnectionError_IsClassifiedAsTransient()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user