feat(inbound-api): accept X-API-Key header as credential transport alongside Authorization: Bearer
This commit is contained in:
@@ -80,14 +80,19 @@ public static class EndpointExtensions
|
|||||||
var routeHelper = httpContext.RequestServices.GetRequiredService<RouteHelper>();
|
var routeHelper = httpContext.RequestServices.GetRequiredService<RouteHelper>();
|
||||||
var options = httpContext.RequestServices.GetRequiredService<IOptions<InboundApiOptions>>().Value;
|
var options = httpContext.RequestServices.GetRequiredService<IOptions<InboundApiOptions>>().Value;
|
||||||
|
|
||||||
// Auth re-arch (A+B): the inbound credential is now a Bearer token
|
// Auth re-arch (A+B) + X-API-Key restore: the inbound credential is accepted
|
||||||
// (Authorization: Bearer sbk_<keyId>_<secret>) verified by the shared
|
// from EITHER the Authorization header ("Bearer sbk_<keyId>_<secret>") OR the
|
||||||
// ZB.MOM.WW.Auth.ApiKeys verifier — peppered-HMAC constant-time secret
|
// legacy "X-API-Key: sbk_<keyId>_<secret>" header (raw token). Both are passed
|
||||||
// compare is handled inside the library verifier. The raw X-API-Key header
|
// to the SAME shared ZB.MOM.WW.Auth.ApiKeys verifier — the parser strips an
|
||||||
// and the in-repo ApiKeyValidator are retired on this path.
|
// 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 authorizationHeader = httpContext.Request.Headers.Authorization.ToString();
|
||||||
|
var credential = !string.IsNullOrWhiteSpace(authorizationHeader)
|
||||||
|
? authorizationHeader
|
||||||
|
: httpContext.Request.Headers["X-API-Key"].ToString();
|
||||||
var verification = await verifier.VerifyAsync(
|
var verification = await verifier.VerifyAsync(
|
||||||
authorizationHeader, httpContext.RequestAborted);
|
credential, httpContext.RequestAborted);
|
||||||
|
|
||||||
if (!verification.Succeeded)
|
if (!verification.Succeeded)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -113,6 +113,105 @@ public sealed class EndpointExtensionsTests : IDisposable
|
|||||||
Assert.Contains("7", body);
|
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_<keyId>_<secret>" 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]
|
[Fact]
|
||||||
public async Task MissingBearer_Returns401()
|
public async Task MissingBearer_Returns401()
|
||||||
{
|
{
|
||||||
@@ -453,6 +552,23 @@ public sealed class EndpointExtensionsTests : IDisposable
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// X-API-Key restore: builds a POST carrying the credential in the legacy
|
||||||
|
/// <c>X-API-Key: sbk_<keyId>_<secret></c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds a key with the given scopes into the library SQLite store via
|
/// Seeds a key with the given scopes into the library SQLite store via
|
||||||
/// <see cref="ApiKeyAdminCommands.CreateKeyAsync"/> (the public admin seam) and
|
/// <see cref="ApiKeyAdminCommands.CreateKeyAsync"/> (the public admin seam) and
|
||||||
|
|||||||
Reference in New Issue
Block a user