From 510559e1be0aa6994f95821551c4364b0cd76198 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 14:01:01 -0400 Subject: [PATCH 1/2] feat(inbound-api): accept X-API-Key header as credential transport alongside Authorization: Bearer --- .../EndpointExtensions.cs | 17 ++- .../EndpointExtensionsTests.cs | 116 ++++++++++++++++++ 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs index c6937003..d1049cb0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs @@ -80,14 +80,19 @@ public static class EndpointExtensions var routeHelper = httpContext.RequestServices.GetRequiredService(); var options = httpContext.RequestServices.GetRequiredService>().Value; - // Auth re-arch (A+B): the inbound credential is now a Bearer token - // (Authorization: Bearer sbk__) verified by the shared - // ZB.MOM.WW.Auth.ApiKeys verifier — peppered-HMAC constant-time secret - // compare is handled inside the library verifier. The raw X-API-Key header - // and the in-repo ApiKeyValidator are retired on this path. + // Auth re-arch (A+B) + X-API-Key restore: the inbound credential is accepted + // from EITHER the Authorization header ("Bearer sbk__") OR the + // legacy "X-API-Key: sbk__" header (raw token). Both are passed + // to the SAME shared ZB.MOM.WW.Auth.ApiKeys verifier — the parser strips an + // optional "Bearer " prefix and otherwise accepts a bare token, so the + // peppered-HMAC constant-time secret compare is identical for both transports. + // Authorization takes precedence when both headers are present. var authorizationHeader = httpContext.Request.Headers.Authorization.ToString(); + var credential = !string.IsNullOrWhiteSpace(authorizationHeader) + ? authorizationHeader + : httpContext.Request.Headers["X-API-Key"].ToString(); var verification = await verifier.VerifyAsync( - authorizationHeader, httpContext.RequestAborted); + credential, httpContext.RequestAborted); if (!verification.Succeeded) { diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs index 4914b433..5fd18d9a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs @@ -113,6 +113,105 @@ public sealed class EndpointExtensionsTests : IDisposable Assert.Contains("7", body); } + [Fact] + public async Task HappyPath_ValidXApiKeyInScope_Returns200WithScriptResultJson() + { + // X-API-Key restore: the SAME seeded key, presented in the legacy + // "X-API-Key: sbk__" header (raw token, no "Bearer " prefix), + // must authenticate through the SAME shared verifier and reach the script — + // proving the broadened credential extraction feeds X-API-Key to VerifyAsync. + var method = SeedMethod(1, "echo", "return Parameters[\"value\"];", + """[{"name":"value","type":"Integer","required":true}]"""); + + using var host = await BuildHostAsync(method); + var token = await SeedKeyAsync(host, keyId: "key1", displayName: "echo-caller", + scopes: new[] { "echo" }); + var client = host.GetTestClient(); + + var request = BuildPostWithApiKeyHeader("echo", """{"value":7}""", token); + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("7", body); + } + + [Fact] + public async Task BothHeadersPresent_AuthorizationWins_ValidBearerBeatsGarbageXApiKey() + { + // Precedence: when BOTH headers are present, the Authorization header is the + // credential passed to the verifier. A VALID Bearer token alongside a GARBAGE + // X-API-Key must authenticate (200) — proving Authorization, not X-API-Key, + // was the value extracted and verified. + var method = SeedMethod(1, "echo", "return Parameters[\"value\"];", + """[{"name":"value","type":"Integer","required":true}]"""); + + using var host = await BuildHostAsync(method); + var validToken = await SeedKeyAsync(host, keyId: "key1", displayName: "echo-caller", + scopes: new[] { "echo" }); + var client = host.GetTestClient(); + + var request = new HttpRequestMessage(HttpMethod.Post, "/api/echo") + { + Content = new StringContent("""{"value":7}""", Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", validToken); + request.Headers.Add("X-API-Key", "sbk_garbage_not-a-real-secret"); + var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("7", body); + } + + [Fact] + public async Task BothHeadersPresent_AuthorizationWins_GarbageBearerBeatsValidXApiKey_Returns401() + { + // Precedence (the converse): a GARBAGE Bearer alongside an otherwise-VALID + // X-API-Key must STILL fail with 401 — the present (non-blank) Authorization + // header takes precedence and is the value handed to the verifier, so the + // valid X-API-Key is never consulted. This nails down that Authorization wins + // because it is PRESENT, not because it happened to be valid. + var method = SeedMethod(1, "echo", "return Parameters[\"value\"];", + """[{"name":"value","type":"Integer","required":true}]"""); + + using var host = await BuildHostAsync(method); + var validToken = await SeedKeyAsync(host, keyId: "key1", displayName: "echo-caller", + scopes: new[] { "echo" }); + var client = host.GetTestClient(); + + var request = new HttpRequestMessage(HttpMethod.Post, "/api/echo") + { + Content = new StringContent("""{"value":7}""", Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-a-real-token"); + request.Headers.Add("X-API-Key", validToken); + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task NeitherHeader_Returns401() + { + // X-API-Key restore must NOT weaken the failure path: with NEITHER the + // Authorization nor the X-API-Key header present, the empty credential still + // fails the verifier and maps to the same generic 401. + var method = SeedMethod(1, "echo", "return 1;"); + + using var host = await BuildHostAsync(method); + await SeedKeyAsync(host, "key1", "echo-caller", new[] { "echo" }); + var client = host.GetTestClient(); + + var request = new HttpRequestMessage(HttpMethod.Post, "/api/echo") + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + }; + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + [Fact] public async Task MissingBearer_Returns401() { @@ -453,6 +552,23 @@ public sealed class EndpointExtensionsTests : IDisposable return request; } + /// + /// X-API-Key restore: builds a POST carrying the credential in the legacy + /// X-API-Key: sbk_<keyId>_<secret> header (the RAW token, no + /// "Bearer " prefix) instead of Authorization. The endpoint must extract this + /// header value and feed it to the SAME shared verifier. + /// + private static HttpRequestMessage BuildPostWithApiKeyHeader( + string methodName, string body, string apiKeyToken) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + request.Headers.Add("X-API-Key", apiKeyToken); + return request; + } + /// /// Seeds a key with the given scopes into the library SQLite store via /// (the public admin seam) and From 1392fd144a5d24b2b5658450d967a952822622c1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 14:06:03 -0400 Subject: [PATCH 2/2] =?UTF-8?q?test(inbound-api):=20X-API-Key=20review=20n?= =?UTF-8?q?its=20=E2=80=94=20whitespace-auth=20fallthrough=20test=20+=20de?= =?UTF-8?q?dupe=20+=20comment=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WhitespaceAuthorization_ValidXApiKey_Returns200: pins the IsNullOrWhiteSpace fall-through — a present-but-blank Authorization header is treated as absent so a valid X-API-Key still authenticates (200). - Remove MissingBearer_Returns401 (added in 510559e): identical path to NeitherHeader_Returns401 (no Authorization + no X-API-Key → 401); keep the descriptively-named NeitherHeader variant. - Change "legacy 'X-API-Key'" -> "alternate 'X-API-Key'" in EndpointExtensions.cs and the BuildPostWithApiKeyHeader/HappyPath doc comments to avoid implying Bearer is the older transport (Bearer was itself introduced by the prior auth re-arch). --- .../EndpointExtensions.cs | 2 +- .../EndpointExtensionsTests.cs | 28 +++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs index d1049cb0..5c70ed53 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs @@ -82,7 +82,7 @@ public static class EndpointExtensions // Auth re-arch (A+B) + X-API-Key restore: the inbound credential is accepted // from EITHER the Authorization header ("Bearer sbk__") OR the - // legacy "X-API-Key: sbk__" header (raw token). Both are passed + // alternate "X-API-Key: sbk__" header (raw token). Both are passed // to the SAME shared ZB.MOM.WW.Auth.ApiKeys verifier — the parser strips an // optional "Bearer " prefix and otherwise accepts a bare token, so the // peppered-HMAC constant-time secret compare is identical for both transports. diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs index 5fd18d9a..4a9f792c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs @@ -116,7 +116,7 @@ public sealed class EndpointExtensionsTests : IDisposable [Fact] public async Task HappyPath_ValidXApiKeyInScope_Returns200WithScriptResultJson() { - // X-API-Key restore: the SAME seeded key, presented in the legacy + // X-API-Key restore: the SAME seeded key, presented in the alternate // "X-API-Key: sbk__" header (raw token, no "Bearer " prefix), // must authenticate through the SAME shared verifier and reach the script — // proving the broadened credential extraction feeds X-API-Key to VerifyAsync. @@ -213,22 +213,28 @@ public sealed class EndpointExtensionsTests : IDisposable } [Fact] - public async Task MissingBearer_Returns401() + public async Task WhitespaceAuthorization_ValidXApiKey_Returns200() { - var method = SeedMethod(1, "noKey", "return 1;"); + // IsNullOrWhiteSpace fall-through: a PRESENT but whitespace-only Authorization + // header is treated as absent, so a valid X-API-Key in the same request must + // still authenticate (200). This pins the IsNullOrWhiteSpace check: without + // it, a non-null-but-blank Authorization value would be passed straight to + // the verifier (which would fail), and the X-API-Key would never be consulted. + var method = SeedMethod(1, "echo", "return Parameters[\"value\"];", + """[{"name":"value","type":"Integer","required":true}]"""); using var host = await BuildHostAsync(method); - await SeedKeyAsync(host, "key1", "noKey-caller", new[] { "noKey" }); + var token = await SeedKeyAsync(host, keyId: "key1", displayName: "echo-caller", + scopes: new[] { "echo" }); var client = host.GetTestClient(); - // No Authorization header — auth should reject with 401. - var request = new HttpRequestMessage(HttpMethod.Post, "/api/noKey") - { - Content = new StringContent("{}", Encoding.UTF8, "application/json"), - }; + var request = BuildPostWithApiKeyHeader("echo", """{"value":7}""", token); + request.Headers.TryAddWithoutValidation("Authorization", " "); var response = await client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("7", body); } [Fact] @@ -553,7 +559,7 @@ public sealed class EndpointExtensionsTests : IDisposable } /// - /// X-API-Key restore: builds a POST carrying the credential in the legacy + /// X-API-Key restore: builds a POST carrying the credential in the alternate /// X-API-Key: sbk_<keyId>_<secret> header (the RAW token, no /// "Bearer " prefix) instead of Authorization. The endpoint must extract this /// header value and feed it to the SAME shared verifier.