From 2e7be1e852c1716e4f0c146262187e20873e35da Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 10 Jul 2026 04:24:11 -0400 Subject: [PATCH] =?UTF-8?q?fix(esg):=20structured=20JSON=20AuthConfigurati?= =?UTF-8?q?on=20(matches=20entity=20doc=20+=20UI=20placeholders)=20with=20?= =?UTF-8?q?legacy=20colon=20fallback=20=E2=80=94=20keys=20containing=20':'?= =?UTF-8?q?=20no=20longer=20misparse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- .../Component-ExternalSystemGateway.md | 6 ++ .../ExternalSystemClient.cs | 86 +++++++++++++++- .../ExternalSystemClientTests.cs | 99 +++++++++++++++++++ 3 files changed, 189 insertions(+), 2 deletions(-) diff --git a/docs/requirements/Component-ExternalSystemGateway.md b/docs/requirements/Component-ExternalSystemGateway.md index 5b22d754..b578f67d 100644 --- a/docs/requirements/Component-ExternalSystemGateway.md +++ b/docs/requirements/Component-ExternalSystemGateway.md @@ -35,6 +35,12 @@ Each external system definition includes: - **Authentication**: One of: - **API Key**: Header name (e.g., `X-API-Key`) and key value. - **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": ""}`. `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": "", "password": ""}`. 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. - **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: diff --git a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs index cea89bf8..6904fb9b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs @@ -591,10 +591,40 @@ public class ExternalSystemClient : IExternalSystemClient 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? jsonFields = null; + var jsonParsed = isJson && TryParseJsonAuth(system.AuthConfiguration, out jsonFields); + switch (authType) { 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); if (parts.Length == 2) { @@ -607,7 +637,28 @@ public class ExternalSystemClient : IExternalSystemClient break; 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); if (basicParts.Length == 2) { @@ -639,6 +690,37 @@ public class ExternalSystemClient : IExternalSystemClient } } + /// + /// Parses a structured JSON AuthConfiguration into a case-insensitive + /// field map (e.g. {"header":"X-Auth","key":"..."} or + /// {"username":"u","password":"p"}). Returns false on malformed + /// JSON () instead of throwing, so the caller can + /// fall through to the "send without header" warning path. Values are never + /// logged. + /// + private static bool TryParseJsonAuth(string config, out Dictionary fields) + { + try + { + fields = JsonSerializer.Deserialize>( + config, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Normalize to case-insensitive so "Header"/"header" both resolve. + if (!ReferenceEquals(fields.Comparer, StringComparer.OrdinalIgnoreCase)) + { + fields = new Dictionary(fields, StringComparer.OrdinalIgnoreCase); + } + return true; + } + catch (JsonException) + { + fields = new Dictionary(StringComparer.OrdinalIgnoreCase); + return false; + } + } + private async Task<(ExternalSystemDefinition? system, ExternalSystemMethod? method)> ResolveSystemAndMethodAsync( string systemName, string methodName, diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs index 13c7b589..514380df 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs @@ -828,6 +828,105 @@ public class ExternalSystemClientTests 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()).Returns(new HttpClient(handler)); + + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.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()).Returns(new HttpClient(handler)); + + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.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()).Returns(new HttpClient(handler)); + + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.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()).Returns(new HttpClient(handler)); + + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance); + + await client.CallAsync("TestAPI", "getData"); + + Assert.True(handler.LastHeaders!.TryGetValues("X-Custom", out var values)); + Assert.Equal("secret", values!.Single()); + } + [Fact] public async Task Call_ConnectionError_IsClassifiedAsTransient() {