fix(cli): no redirects on the management client, race-safe token refresh, verbose Basic-fallback note (review 2026-10-21 05/U1, 05/P1 L1/L2/L3/L4)
- CreateDefaultHttpClient sets AllowAutoRedirect=false so a 307/308 cannot re-POST the /auth/token credential form elsewhere. - A forced re-exchange after a 401 carries the refused token; inside the gate it reuses a token another caller already re-exchanged instead of binding again. The BasicOnly switch is written under the same gate. - SCADABRIDGE_VERBOSE=1 prints a stderr note when a fresh token is refused and the CLI falls back to Basic. - debug stream's AccessTokenProvider retries the exchange once and documents the null-token degradation (the hub refuses, automatic reconnect retries). - Tests: concurrent refusal reuses the peer refresh (2 exchanges, not 3); real-socket redirect is not followed.
This commit is contained in:
@@ -21,6 +21,7 @@ public class ManagementHttpClientTokenTests
|
||||
public List<(string Path, string? Auth)> Requests { get; } = [];
|
||||
public Func<HttpResponseMessage>? TokenResponse { get; set; }
|
||||
public Func<string?, HttpResponseMessage>? ManagementResponse { get; set; }
|
||||
public Func<string?, Task>? BeforeManagementResponse { get; set; }
|
||||
|
||||
public HttpResponseMessage IssueToken(int expiresIn = 900)
|
||||
{
|
||||
@@ -42,6 +43,8 @@ public class ManagementHttpClientTokenTests
|
||||
return TokenResponse?.Invoke() ?? IssueToken();
|
||||
}
|
||||
|
||||
if (BeforeManagementResponse is not null)
|
||||
await BeforeManagementResponse(auth);
|
||||
return ManagementResponse?.Invoke(auth) ?? Json(HttpStatusCode.OK, "{\"ok\":true}");
|
||||
}
|
||||
}
|
||||
@@ -225,4 +228,81 @@ public class ManagementHttpClientTokenTests
|
||||
|
||||
Assert.Equal(new[] { ("/management", (string?)BasicHeader) }, server.Requests);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two callers refused the same token: the second must reuse the token the first already
|
||||
/// re-exchanged instead of binding again (review L3).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConcurrentRefusal_ReusesPeerRefresh_InsteadOfSecondExchange()
|
||||
{
|
||||
var server = new FakeServer();
|
||||
var store = new CliTokenStore();
|
||||
using var a = Client(server, store);
|
||||
using var b = Client(server, store);
|
||||
await a.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); // tok-1 cached
|
||||
|
||||
server.ManagementResponse = auth => auth == "Bearer tok-1"
|
||||
? Json(HttpStatusCode.Unauthorized, "{\"error\":\"expired\"}")
|
||||
: Json(HttpStatusCode.OK, "{\"ok\":true}");
|
||||
var nested = false;
|
||||
server.BeforeManagementResponse = async auth =>
|
||||
{
|
||||
// While B's tok-1 request is in flight, A hits the same refusal and refreshes.
|
||||
if (auth == "Bearer tok-1" && !nested)
|
||||
{
|
||||
nested = true;
|
||||
Assert.Equal(200, (await a.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5))).StatusCode);
|
||||
}
|
||||
};
|
||||
|
||||
var response = await b.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(200, response.StatusCode);
|
||||
Assert.Equal(2, server.Requests.Count(r => r.Path == "/auth/token"));
|
||||
Assert.Equal("Bearer tok-2", server.Requests[^1].Auth);
|
||||
}
|
||||
|
||||
/// <summary>The production HttpClient never follows redirects, so credentials cannot be replayed (review L2).</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TokenExchange_DoesNotFollowRedirects()
|
||||
{
|
||||
var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var requestLines = new List<string>();
|
||||
var serve = Task.Run(async () =>
|
||||
{
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
using var tcp = await listener.AcceptTcpClientAsync();
|
||||
await using var stream = tcp.GetStream();
|
||||
var buffer = new byte[8192];
|
||||
var read = await stream.ReadAsync(buffer);
|
||||
requestLines.Add(Encoding.ASCII.GetString(buffer, 0, read).Split("\r\n")[0]);
|
||||
var reply = i == 0
|
||||
? $"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://127.0.0.1:{port}/stolen\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}";
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes(reply));
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new ManagementHttpClient(
|
||||
ManagementHttpClient.CreateDefaultHttpClient(), $"http://127.0.0.1:{port}", "user", "pass", new CliTokenStore());
|
||||
await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await serve.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
listener.Stop();
|
||||
}
|
||||
|
||||
Assert.StartsWith("POST /auth/token", requestLines[0]);
|
||||
Assert.DoesNotContain(requestLines, l => l.Contains("/stolen", StringComparison.Ordinal));
|
||||
Assert.StartsWith("POST /management", requestLines[1]); // 307 is a non-success → Basic for that call
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user