From 93020f887a735b23965d576d29c7dde194beb40d Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:56:25 -0400 Subject: [PATCH 1/7] [AI-3363] Log the server's error code and bearer expiry on every 401 The handler must sit inside SignalR's token handler to see the bearer it sent, so it is installed through HttpMessageHandlerFactory rather than wrapped around the client. Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.Cli.Core/Auth/JwtClaims.cs | 24 +++-- .../Http/BearerRejectionReportHandler.cs | 70 +++++++++++++++ .../Services/ServerConnection.cs | 12 ++- src/Capacitor.Cli/Commands/WatchCommand.cs | 2 + .../Auth/JwtClaimsTests.cs | 12 +++ .../Http/BearerRejectionReportHandlerTests.cs | 87 +++++++++++++++++++ .../HubBearerRejectionReportTests.cs | 52 +++++++++++ 7 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs create mode 100644 test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs create mode 100644 test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs diff --git a/src/Capacitor.Cli.Core/Auth/JwtClaims.cs b/src/Capacitor.Cli.Core/Auth/JwtClaims.cs index c90e1a1cfa..56aac0cc80 100644 --- a/src/Capacitor.Cli.Core/Auth/JwtClaims.cs +++ b/src/Capacitor.Cli.Core/Auth/JwtClaims.cs @@ -2,13 +2,23 @@ namespace Capacitor.Cli.Core.Auth; -/// Reads one string claim out of a JWT payload WITHOUT validating the signature — the server -/// validates via JWKS; a client only ever uses these values for display and row classification, +/// Reads claims out of a JWT payload WITHOUT validating the signature — the server validates via +/// JWKS; a client only ever uses these values for display, diagnostics and row classification, /// never for authorization. public static class JwtClaims { - public static string? TryGetString(string accessToken, string claimName) { + public static string? TryGetString(string accessToken, string claimName) => + TryReadPayload(accessToken, root => root.Str(claimName)); + + /// A NumericDate claim (exp, iat), or null when absent or not a number. + public static DateTimeOffset? TryGetTime(string accessToken, string claimName) => + TryReadPayload(accessToken, root => + root.TryGetProperty(claimName, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var seconds) + ? DateTimeOffset.FromUnixTimeSeconds(seconds) + : (DateTimeOffset?)null); + + static T? TryReadPayload(string accessToken, Func read) { var parts = accessToken.Split('.'); - if (parts.Length < 2) return null; + if (parts.Length < 2) return default; try { var payload = parts[1].Replace('-', '+').Replace('_', '/'); @@ -19,9 +29,9 @@ public static class JwtClaims { _ => payload, }; using var doc = JsonDocument.Parse(Convert.FromBase64String(payload)); - return doc.RootElement.Str(claimName); - } catch (Exception e) when (e is FormatException or JsonException) { - return null; + return read(doc.RootElement); + } catch (Exception e) when (e is FormatException or JsonException or ArgumentOutOfRangeException) { + return default; } } } diff --git a/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs new file mode 100644 index 0000000000..33655eb5cb --- /dev/null +++ b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Text.Json; +using Capacitor.Cli.Core.Auth; + +namespace Capacitor.Cli.Core.Http; + +/// +/// Reports each 401 with what the client can see of it: the server's error code and the +/// presented bearer's subject, issue time and expiry against local time — never the bearer itself. +/// Observes only; the response reaches the caller unchanged. Must sit inside whatever applies the +/// bearer, so the request it sees carries the one the server refused. +/// +internal sealed class BearerRejectionReportHandler(TimeProvider time, Action report) : DelegatingHandler { + const int MaxBodyBytes = 4096; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) { + var response = await base.SendAsync(request, ct); + + if (response.StatusCode != HttpStatusCode.Unauthorized) return response; + + try { + report(Describe(request, await ReadErrorCodeAsync(response, ct), time.GetUtcNow())); + } catch (Exception ex) when (ex is not OperationCanceledException) { + // A diagnostic must never change what the caller gets back. + } + + return response; + } + + internal static string Describe(HttpRequestMessage request, string? errorCode, DateTimeOffset now) { + var bearer = request.Headers.Authorization is { Scheme: var scheme, Parameter: { Length: > 0 } token } + && scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase) + ? DescribeBearer(token, now) + : "no bearer sent"; + + return $"401 from {request.Method} {request.RequestUri?.AbsolutePath}: server error={errorCode ?? "-"}; {bearer}; local_now={now:o}"; + } + + static string DescribeBearer(string token, DateTimeOffset now) { + var exp = JwtClaims.TryGetTime(token, "exp"); + var iat = JwtClaims.TryGetTime(token, "iat"); + var sub = JwtClaims.TryGetString(token, "sub"); + + if (exp is null && iat is null && sub is null) return "bearer is not a readable JWT"; + + var expiresIn = exp is { } e ? $" ({(long)(e - now).TotalSeconds}s from now)" : ""; + + return $"bearer sub={sub ?? "-"} iat={iat?.ToString("o") ?? "-"} exp={exp?.ToString("o") ?? "-"}{expiresIn}"; + } + + // The server answers a refused bearer with {"error": "...", "message": "..."}. Buffered so a caller + // that reads the body still can. + static async Task ReadErrorCodeAsync(HttpResponseMessage response, CancellationToken ct) { + if (response.Content.Headers.ContentLength is > MaxBodyBytes) return null; + + try { + await response.Content.LoadIntoBufferAsync(MaxBodyBytes, ct); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + + return doc.RootElement.ValueKind == JsonValueKind.Object + && doc.RootElement.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.String + ? error.GetString() + : null; + } catch (Exception ex) when (ex is JsonException or HttpRequestException or InvalidOperationException) { + return null; + } + } +} diff --git a/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs b/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs index 1c03b75fe0..e0e80455e6 100644 --- a/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs +++ b/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs @@ -5,6 +5,7 @@ using Capacitor.Cli.Core.Auth; using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Eval.Contracts; +using Capacitor.Cli.Core.Http; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -209,7 +210,14 @@ public ServerConnection( _tokens = tokens; _logger = logger; _statusNotifier = statusNotifier ?? new(); - _eventQueue = new(config, tokens, time, loggerFactory.CreateLogger(), new HttpClient()); + + // A refused bearer is logged with its expiry and the server's error code on both lanes that + // carry it, so a 401 can be told apart from a lapsed token without the server's log. + void ReportRejection(string report) => logger.LogWarning("Server refused the daemon's bearer — {Report}", report); + + _eventQueue = new( + config, tokens, time, loggerFactory.CreateLogger(), + new HttpClient(new BearerRejectionReportHandler(time, ReportRejection) { InnerHandler = new HttpClientHandler() })); _hub = new HubConnectionBuilder() .WithUrl( @@ -220,6 +228,8 @@ public ServerConnection( return resolution.Tokens?.AccessToken; }; + options.HttpMessageHandlerFactory = inner => + new BearerRejectionReportHandler(time, ReportRejection) { InnerHandler = inner }; } ) .WithAutomaticReconnect(new RetryPolicy()) diff --git a/src/Capacitor.Cli/Commands/WatchCommand.cs b/src/Capacitor.Cli/Commands/WatchCommand.cs index 3783103c4a..7f0c128e75 100644 --- a/src/Capacitor.Cli/Commands/WatchCommand.cs +++ b/src/Capacitor.Cli/Commands/WatchCommand.cs @@ -52,6 +52,8 @@ internal HubConnection BuildHubConnection(string hubUrl) { // outage must send whatever the credential is by then, not the one this watcher // started with. options.AccessTokenProvider = async () => (await credentials.ResolveAsync(default)).Bearer; + options.HttpMessageHandlerFactory = inner => + new BearerRejectionReportHandler(time, report => Log(time, report)) { InnerHandler = inner }; } ) .WithAutomaticReconnect(new InfiniteRetryPolicy()) diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Auth/JwtClaimsTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Auth/JwtClaimsTests.cs index 9baf202cc5..07b33fe09e 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Auth/JwtClaimsTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Auth/JwtClaimsTests.cs @@ -44,4 +44,16 @@ public async Task PayloadNeedingBase64PaddingParses() { [Arguments("null")] public async Task NonObjectPayloadIsNullNeverThrows(string payload) => await Assert.That(JwtClaims.TryGetString(Token(payload), "sub")).IsNull(); + + [Test] + public async Task ReadsANumericDateClaim() => + await Assert.That(JwtClaims.TryGetTime(Token("""{"exp":1790000000}"""), "exp")) + .IsEqualTo(DateTimeOffset.FromUnixTimeSeconds(1790000000)); + + [Test] + [Arguments("""{"exp":"soon"}""")] + [Arguments("""{"sub":"u"}""")] + [Arguments("""{"exp":1e300}""")] + public async Task ANonNumericOrMissingTimeIsNullNeverThrows(string payload) => + await Assert.That(JwtClaims.TryGetTime(Token(payload), "exp")).IsNull(); } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs new file mode 100644 index 0000000000..44d2d51689 --- /dev/null +++ b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs @@ -0,0 +1,87 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Capacitor.Cli.Core.Http; +using Microsoft.Extensions.Time.Testing; + +namespace Capacitor.Cli.Core.Tests.Unit.Http; + +/// +/// A 401 is reported with the server's error code and the refused bearer's subject, issue time and +/// expiry against local time — never the bearer — and the caller still gets the response, body +/// included, exactly as the server sent it. +/// +public class BearerRejectionReportHandlerTests { + static readonly DateTimeOffset Now = new(2026, 9, 29, 22, 0, 0, TimeSpan.Zero); + + sealed class Answer(HttpStatusCode status, string body) : HttpMessageHandler { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => + Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + } + + static string Token(long exp, long iat, string sub) { + static string B64Url(string s) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(s)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + return $"{B64Url("""{"alg":"RS256"}""")}.{B64Url($$"""{"sub":"{{sub}}","exp":{{exp}},"iat":{{iat}}}""")}.sig"; + } + + static async Task<(HttpResponseMessage response, List reports)> SendAsync( + HttpStatusCode status, string body, string? bearer) { + var reports = new List(); + var client = new HttpClient(new BearerRejectionReportHandler(new FakeTimeProvider(Now), reports.Add) { + InnerHandler = new Answer(status, body) + }); + + using var request = new HttpRequestMessage(HttpMethod.Post, "https://tenant.example/hubs/sessions/negotiate?negotiateVersion=1"); + + if (bearer is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + + return (await client.SendAsync(request), reports); + } + + [Test] + public async Task A_401_reports_the_error_code_and_the_refused_bearers_times() { + var token = Token(exp: Now.AddSeconds(-90).ToUnixTimeSeconds(), iat: Now.AddMinutes(-6).ToUnixTimeSeconds(), sub: "user_1"); + + var (response, reports) = await SendAsync( + HttpStatusCode.Unauthorized, """{"error":"token_expired","message":"Token has expired."}""", token); + + await Assert.That(reports).Count().IsEqualTo(1); + await Assert.That(reports[0]).Contains("401 from POST /hubs/sessions/negotiate:"); + await Assert.That(reports[0]).Contains("server error=token_expired"); + await Assert.That(reports[0]).Contains("sub=user_1"); + await Assert.That(reports[0]).Contains("(-90s from now)"); + await Assert.That(reports[0]).DoesNotContain(token).Because("the bearer itself must never be logged"); + await Assert.That(reports[0]).DoesNotContain("negotiateVersion").Because("a query can carry an access token"); + + await Assert.That(await response.Content.ReadAsStringAsync()).Contains("token_expired") + .Because("reading the error code must leave the body readable for the caller"); + } + + [Test] + public async Task A_401_with_no_bearer_says_so() { + var (_, reports) = await SendAsync(HttpStatusCode.Unauthorized, """{"error":"unauthenticated"}""", bearer: null); + + await Assert.That(reports).Count().IsEqualTo(1); + await Assert.That(reports[0]).Contains("server error=unauthenticated; no bearer sent"); + } + + [Test] + public async Task A_401_with_an_unreadable_body_or_bearer_still_reports() { + var (_, reports) = await SendAsync(HttpStatusCode.Unauthorized, "nope", bearer: "opaque"); + + await Assert.That(reports).Count().IsEqualTo(1); + await Assert.That(reports[0]).Contains("server error=-; bearer is not a readable JWT"); + } + + [Test] + [Arguments(HttpStatusCode.OK)] + [Arguments(HttpStatusCode.Forbidden)] + [Arguments(HttpStatusCode.ServiceUnavailable)] + public async Task Other_statuses_report_nothing(HttpStatusCode status) { + var (_, reports) = await SendAsync(status, "{}", bearer: "opaque"); + + await Assert.That(reports).IsEmpty(); + } +} diff --git a/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs b/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs new file mode 100644 index 0000000000..64e470007e --- /dev/null +++ b/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs @@ -0,0 +1,52 @@ +using System.Text; +using Capacitor.Cli.Core.Http; +using Microsoft.AspNetCore.SignalR.Client; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Capacitor.Cli.Tests.Integration; + +/// +/// Installed through HttpMessageHandlerFactory, the report handler sits inside SignalR's own +/// token handler, so a refused negotiate is reported with the bearer SignalR actually sent. If the +/// handler sat outside it, every report would read "no bearer sent" and hide the one fact it exists for. +/// +public class HubBearerRejectionReportTests : IDisposable { + readonly WireMockServer _server = WireMockServer.Start(); + + public void Dispose() => _server.Stop(); + + static string Token(string sub) { + static string B64Url(string s) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(s)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + return $"{B64Url("""{"alg":"RS256"}""")}.{B64Url($$"""{"sub":"{{sub}}","exp":1790000000}""")}.sig"; + } + + [Test] + public async Task A_refused_negotiate_is_reported_with_the_bearer_signalr_sent() { + _server.Given(Request.Create().WithPath("/hubs/sessions/negotiate").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(401).WithHeader("Content-Type", "application/json") + .WithBody("""{"error":"invalid_token","message":"Invalid"}""")); + + var reports = new List(); + + await using var hub = new HubConnectionBuilder() + .WithUrl($"{_server.Url}/hubs/sessions", options => { + options.AccessTokenProvider = () => Task.FromResult(Token("user_hub")); + options.HttpMessageHandlerFactory = inner => + new BearerRejectionReportHandler(TimeProvider.System, r => { lock (reports) reports.Add(r); }) { InnerHandler = inner }; + }) + .Build(); + + try { + await hub.StartAsync(new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token); + } catch (Exception) { + // Refused at negotiate, as stubbed. + } + + await Assert.That(reports).Count().IsEqualTo(1); + await Assert.That(reports[0]).Contains("server error=invalid_token; bearer sub=user_hub"); + } +} From abf58b36a163c32df3fadb4a198cc20442bab9bc Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:11:06 -0400 Subject: [PATCH 2/7] Report a refused WebSocket upgrade and keep an oversized 401 body intact Over HTTP/1.1 SignalR opens the WebSocket outside every message handler, so the hubs take a WebSocket factory that records the upgrade's 401. Co-Authored-By: Claude Opus 5.5 --- .../Http/BearerRejectionReportHandler.cs | 141 ++++++++++++++++-- .../Services/ServerConnection.cs | 2 + src/Capacitor.Cli/Commands/WatchCommand.cs | 2 + .../Http/BearerRejectionReportHandlerTests.cs | 42 +++++- .../HubBearerRejectionReportTests.cs | 38 +++++ 5 files changed, 206 insertions(+), 19 deletions(-) diff --git a/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs index 33655eb5cb..b4d032b3ff 100644 --- a/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs +++ b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs @@ -1,4 +1,6 @@ using System.Net; +using System.Net.WebSockets; +using System.Text; using System.Text.Json; using Capacitor.Cli.Core.Auth; @@ -18,8 +20,15 @@ protected override async Task SendAsync(HttpRequestMessage if (response.StatusCode != HttpStatusCode.Unauthorized) return response; + var bearer = request.Headers.Authorization is { Scheme: var scheme, Parameter: { Length: > 0 } token } + && scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase) + ? token + : null; + try { - report(Describe(request, await ReadErrorCodeAsync(response, ct), time.GetUtcNow())); + var errorCode = await ReadErrorCodeAsync(response, ct); + + report(Describe(request.Method.Method, request.RequestUri, errorCode, bearer, time.GetUtcNow())); } catch (Exception ex) when (ex is not OperationCanceledException) { // A diagnostic must never change what the caller gets back. } @@ -27,15 +36,50 @@ protected override async Task SendAsync(HttpRequestMessage return response; } - internal static string Describe(HttpRequestMessage request, string? errorCode, DateTimeOffset now) { - var bearer = request.Headers.Authorization is { Scheme: var scheme, Parameter: { Length: > 0 } token } - && scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase) - ? DescribeBearer(token, now) - : "no bearer sent"; + /// + /// A SignalR WebSocketFactory body. Over HTTP/1.1 SignalR opens the WebSocket itself, outside + /// any configured message handler, so a 401 on the upgrade would otherwise go unreported. Sets what + /// SignalR's own factory sets that the server acts on — the bearer, and X-Requested-With, which + /// makes cookie auth answer 401 instead of redirecting. + /// + public static async ValueTask ConnectWebSocketAsync( + Uri uri, Func>? accessTokenProvider, TimeProvider time, Action report, CancellationToken ct) { + var webSocket = new ClientWebSocket(); + + webSocket.Options.CollectHttpResponseDetails = true; + webSocket.Options.SetRequestHeader("X-Requested-With", "XMLHttpRequest"); + + var bearer = accessTokenProvider is null ? null : await accessTokenProvider(); + + if (!string.IsNullOrWhiteSpace(bearer)) webSocket.Options.SetRequestHeader("Authorization", $"Bearer {bearer}"); + + try { + await webSocket.ConnectAsync(uri, ct); - return $"401 from {request.Method} {request.RequestUri?.AbsolutePath}: server error={errorCode ?? "-"}; {bearer}; local_now={now:o}"; + return webSocket; + } catch (WebSocketException) { + if (webSocket.HttpStatusCode == HttpStatusCode.Unauthorized) { + try { + report(Describe("GET", uri, errorCode: null, string.IsNullOrWhiteSpace(bearer) ? null : bearer, time.GetUtcNow())); + } catch { + // A diagnostic must never change the exception the caller sees. + } + } + + webSocket.Dispose(); + + throw; + } catch { + webSocket.Dispose(); + + throw; + } } + internal static string Describe(string method, Uri? uri, string? errorCode, string? bearer, DateTimeOffset now) => + $"401 from {method} {uri?.AbsolutePath}: server error={errorCode ?? "-"}; " + + $"{(bearer is null ? "no bearer sent" : DescribeBearer(bearer, now))}; local_now={now:o}"; + static string DescribeBearer(string token, DateTimeOffset now) { var exp = JwtClaims.TryGetTime(token, "exp"); var iat = JwtClaims.TryGetTime(token, "iat"); @@ -48,23 +92,92 @@ static string DescribeBearer(string token, DateTimeOffset now) { return $"bearer sub={sub ?? "-"} iat={iat?.ToString("o") ?? "-"} exp={exp?.ToString("o") ?? "-"}{expiresIn}"; } - // The server answers a refused bearer with {"error": "...", "message": "..."}. Buffered so a caller - // that reads the body still can. + // The server answers a refused bearer with {"error": "...", "message": "..."}. Reads at most one byte + // past the cap and puts back everything read, so the caller receives the body intact at any size. static async Task ReadErrorCodeAsync(HttpResponseMessage response, CancellationToken ct) { - if (response.Content.Headers.ContentLength is > MaxBodyBytes) return null; + var original = response.Content; + var body = await original.ReadAsStreamAsync(ct); + var head = new byte[MaxBodyBytes + 1]; + var read = 0; - try { - await response.Content.LoadIntoBufferAsync(MaxBodyBytes, ct); + while (read < head.Length) { + var n = await body.ReadAsync(head.AsMemory(read), ct); + + if (n == 0) break; + + read += n; + } + + var complete = read <= MaxBodyBytes; + + HttpContent replacement = complete + ? new ByteArrayContent(head, 0, read) + : new StreamContent(new PrefixedStream(head.AsMemory(0, read), body)); - using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + foreach (var header in original.Headers) replacement.Headers.TryAddWithoutValidation(header.Key, header.Value); + + response.Content = replacement; + + // An oversized body's stream now belongs to the replacement; the original must not close it. + if (!complete) return null; + + original.Dispose(); + + try { + using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(head, 0, read)); return doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.String ? error.GetString() : null; - } catch (Exception ex) when (ex is JsonException or HttpRequestException or InvalidOperationException) { + } catch (JsonException) { return null; } } + + // The bytes already read, then the rest of the original stream. + sealed class PrefixedStream(ReadOnlyMemory prefix, Stream rest) : Stream { + int _offset; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + public override int Read(Span buffer) { + if (_offset < prefix.Length) return CopyPrefix(buffer); + + return rest.Read(buffer); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + ReadAsync(buffer.AsMemory(offset, count), ct).AsTask(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) => + _offset < prefix.Length ? ValueTask.FromResult(CopyPrefix(buffer.Span)) : rest.ReadAsync(buffer, ct); + + int CopyPrefix(Span buffer) { + var n = Math.Min(buffer.Length, prefix.Length - _offset); + + prefix.Span.Slice(_offset, n).CopyTo(buffer); + _offset += n; + + return n; + } + + protected override void Dispose(bool disposing) { + if (disposing) rest.Dispose(); + + base.Dispose(disposing); + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } } diff --git a/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs b/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs index e0e80455e6..69adbe1ae9 100644 --- a/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs +++ b/src/Capacitor.Cli.Daemon/Services/ServerConnection.cs @@ -230,6 +230,8 @@ public ServerConnection( }; options.HttpMessageHandlerFactory = inner => new BearerRejectionReportHandler(time, ReportRejection) { InnerHandler = inner }; + options.WebSocketFactory = (context, ct) => BearerRejectionReportHandler.ConnectWebSocketAsync( + context.Uri, context.Options.AccessTokenProvider, time, ReportRejection, ct); } ) .WithAutomaticReconnect(new RetryPolicy()) diff --git a/src/Capacitor.Cli/Commands/WatchCommand.cs b/src/Capacitor.Cli/Commands/WatchCommand.cs index 7f0c128e75..104bf10739 100644 --- a/src/Capacitor.Cli/Commands/WatchCommand.cs +++ b/src/Capacitor.Cli/Commands/WatchCommand.cs @@ -54,6 +54,8 @@ internal HubConnection BuildHubConnection(string hubUrl) { options.AccessTokenProvider = async () => (await credentials.ResolveAsync(default)).Bearer; options.HttpMessageHandlerFactory = inner => new BearerRejectionReportHandler(time, report => Log(time, report)) { InnerHandler = inner }; + options.WebSocketFactory = (context, ct) => BearerRejectionReportHandler.ConnectWebSocketAsync( + context.Uri, context.Options.AccessTokenProvider, time, report => Log(time, report), ct); } ) .WithAutomaticReconnect(new InfiniteRetryPolicy()) diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs index 44d2d51689..2fe94b7527 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs @@ -14,9 +14,19 @@ namespace Capacitor.Cli.Core.Tests.Unit.Http; public class BearerRejectionReportHandlerTests { static readonly DateTimeOffset Now = new(2026, 9, 29, 22, 0, 0, TimeSpan.Zero); - sealed class Answer(HttpStatusCode status, string body) : HttpMessageHandler { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => - Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + sealed class Answer(HttpStatusCode status, string body, bool unknownLength = false) : HttpMessageHandler { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) { + HttpContent content = unknownLength + ? new StreamContent(new ForwardOnly(Encoding.UTF8.GetBytes(body))) + : new StringContent(body, Encoding.UTF8, "application/json"); + + return Task.FromResult(new HttpResponseMessage(status) { Content = content }); + } + } + + // A chunked body: no Content-Length, readable once. + sealed class ForwardOnly(byte[] bytes) : MemoryStream(bytes) { + public override bool CanSeek => false; } static string Token(long exp, long iat, string sub) { @@ -27,10 +37,10 @@ static string B64Url(string s) => } static async Task<(HttpResponseMessage response, List reports)> SendAsync( - HttpStatusCode status, string body, string? bearer) { + HttpStatusCode status, string body, string? bearer, bool unknownLength = false) { var reports = new List(); var client = new HttpClient(new BearerRejectionReportHandler(new FakeTimeProvider(Now), reports.Add) { - InnerHandler = new Answer(status, body) + InnerHandler = new Answer(status, body, unknownLength) }); using var request = new HttpRequestMessage(HttpMethod.Post, "https://tenant.example/hubs/sessions/negotiate?negotiateVersion=1"); @@ -84,4 +94,26 @@ public async Task Other_statuses_report_nothing(HttpStatusCode status) { await Assert.That(reports).IsEmpty(); } + + [Test] + public async Task A_small_body_of_unknown_length_yields_the_code_and_stays_readable() { + var (response, reports) = await SendAsync( + HttpStatusCode.Unauthorized, """{"error":"invalid_token"}""", bearer: null, unknownLength: true); + + await Assert.That(reports[0]).Contains("server error=invalid_token"); + await Assert.That(await response.Content.ReadAsStringAsync()).IsEqualTo("""{"error":"invalid_token"}"""); + } + + [Test] + public async Task An_oversized_body_of_unknown_length_reaches_the_caller_intact() { + var body = "{\"error\":\"invalid_token\",\"pad\":\"" + new string('x', 10_000) + "\"}"; + + var (response, reports) = await SendAsync(HttpStatusCode.Unauthorized, body, bearer: null, unknownLength: true); + + await Assert.That(reports).Count().IsEqualTo(1); + await Assert.That(reports[0]).Contains("server error=-") + .Because("a body past the cap is not parsed"); + await Assert.That(await response.Content.ReadAsStringAsync()).IsEqualTo(body) + .Because("the bytes read while probing must be put back in front of the rest"); + } } diff --git a/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs b/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs index 64e470007e..d2600f3fda 100644 --- a/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs @@ -11,6 +11,7 @@ namespace Capacitor.Cli.Tests.Integration; /// Installed through HttpMessageHandlerFactory, the report handler sits inside SignalR's own /// token handler, so a refused negotiate is reported with the bearer SignalR actually sent. If the /// handler sat outside it, every report would read "no bearer sent" and hide the one fact it exists for. +/// The WebSocket upgrade bypasses every message handler, so it is reported by the WebSocket factory. /// public class HubBearerRejectionReportTests : IDisposable { readonly WireMockServer _server = WireMockServer.Start(); @@ -49,4 +50,41 @@ public async Task A_refused_negotiate_is_reported_with_the_bearer_signalr_sent() await Assert.That(reports).Count().IsEqualTo(1); await Assert.That(reports[0]).Contains("server error=invalid_token; bearer sub=user_hub"); } + + [Test] + public async Task A_refused_websocket_upgrade_is_reported_with_the_bearer_sent() { + _server.Given(Request.Create().WithPath("/hubs/sessions/negotiate").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200).WithHeader("Content-Type", "application/json") + .WithBody("""{"negotiateVersion":1,"connectionId":"c1","connectionToken":"t1","availableTransports":[{"transport":"WebSockets","transferFormats":["Text","Binary"]}]}""")); + _server.Given(Request.Create().WithPath("/hubs/sessions").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(401)); + + var reports = new List(); + void Report(string r) { lock (reports) reports.Add(r); } + + await using var hub = new HubConnectionBuilder() + .WithUrl($"{_server.Url}/hubs/sessions", options => { + options.AccessTokenProvider = () => Task.FromResult(Token("user_ws")); + options.HttpMessageHandlerFactory = inner => + new BearerRejectionReportHandler(TimeProvider.System, Report) { InnerHandler = inner }; + options.WebSocketFactory = (context, ct) => BearerRejectionReportHandler.ConnectWebSocketAsync( + context.Uri, context.Options.AccessTokenProvider, TimeProvider.System, Report, ct); + }) + .Build(); + + try { + await hub.StartAsync(new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token); + } catch (Exception) { + // Refused at the upgrade, as stubbed. + } + + await Assert.That(reports).Count().IsEqualTo(1); + await Assert.That(reports[0]).Contains("401 from GET /hubs/sessions: server error=-; bearer sub=user_ws"); + + var upgrade = _server.LogEntries.Single(e => e.RequestMessage.Method == "GET"); + + await Assert.That(upgrade.RequestMessage.Headers!["Authorization"].Single()).IsEqualTo("Bearer " + Token("user_ws")) + .Because("the factory replaces SignalR's own, so it must still send the bearer"); + await Assert.That(upgrade.RequestMessage.Headers!["X-Requested-With"].Single()).IsEqualTo("XMLHttpRequest"); + } } From 5af200109d591808be1db1be5e5ca6bd36304f00 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:16:25 -0400 Subject: [PATCH 3/7] Read JWT times only from an object payload Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.Cli.Core/Auth/JwtClaims.cs | 3 ++- .../Auth/JwtClaimsTests.cs | 3 +++ .../Http/BearerRejectionReportHandlerTests.cs | 11 +++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Capacitor.Cli.Core/Auth/JwtClaims.cs b/src/Capacitor.Cli.Core/Auth/JwtClaims.cs index 56aac0cc80..000dcf29cd 100644 --- a/src/Capacitor.Cli.Core/Auth/JwtClaims.cs +++ b/src/Capacitor.Cli.Core/Auth/JwtClaims.cs @@ -12,7 +12,8 @@ public static class JwtClaims { /// A NumericDate claim (exp, iat), or null when absent or not a number. public static DateTimeOffset? TryGetTime(string accessToken, string claimName) => TryReadPayload(accessToken, root => - root.TryGetProperty(claimName, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var seconds) + root.ValueKind == JsonValueKind.Object + && root.TryGetProperty(claimName, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var seconds) ? DateTimeOffset.FromUnixTimeSeconds(seconds) : (DateTimeOffset?)null); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Auth/JwtClaimsTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Auth/JwtClaimsTests.cs index 07b33fe09e..4863975122 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Auth/JwtClaimsTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Auth/JwtClaimsTests.cs @@ -54,6 +54,9 @@ await Assert.That(JwtClaims.TryGetTime(Token("""{"exp":1790000000}"""), "exp")) [Arguments("""{"exp":"soon"}""")] [Arguments("""{"sub":"u"}""")] [Arguments("""{"exp":1e300}""")] + [Arguments("null")] + [Arguments("[1]")] + [Arguments("\"text\"")] public async Task ANonNumericOrMissingTimeIsNullNeverThrows(string payload) => await Assert.That(JwtClaims.TryGetTime(Token(payload), "exp")).IsNull(); } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs index 2fe94b7527..74ec28d140 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs @@ -116,4 +116,15 @@ await Assert.That(reports[0]).Contains("server error=-") await Assert.That(await response.Content.ReadAsStringAsync()).IsEqualTo(body) .Because("the bytes read while probing must be put back in front of the rest"); } + + [Test] + public async Task A_bearer_whose_payload_is_not_an_object_still_reports() { + static string B64Url(string s) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(s)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + var (_, reports) = await SendAsync(HttpStatusCode.Unauthorized, "{}", bearer: $"{B64Url("{}")}.{B64Url("[]")}.sig"); + + await Assert.That(reports).Count().IsEqualTo(1); + await Assert.That(reports[0]).Contains("bearer is not a readable JWT"); + } } From 7ba0575f2958c446f6a71aa7233983b0541c2d62 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:36:06 -0400 Subject: [PATCH 4/7] Neutralise control characters in 401 reports and use the JSON helpers Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.Cli.Core/Auth/JwtClaims.cs | 6 +----- .../Http/BearerRejectionReportHandler.cs | 20 ++++++++++++------- .../Http/BearerRejectionReportHandlerTests.cs | 13 ++++++++++++ .../HubBearerRejectionReportTests.cs | 8 ++------ 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/Capacitor.Cli.Core/Auth/JwtClaims.cs b/src/Capacitor.Cli.Core/Auth/JwtClaims.cs index 000dcf29cd..a361fff07a 100644 --- a/src/Capacitor.Cli.Core/Auth/JwtClaims.cs +++ b/src/Capacitor.Cli.Core/Auth/JwtClaims.cs @@ -11,11 +11,7 @@ public static class JwtClaims { /// A NumericDate claim (exp, iat), or null when absent or not a number. public static DateTimeOffset? TryGetTime(string accessToken, string claimName) => - TryReadPayload(accessToken, root => - root.ValueKind == JsonValueKind.Object - && root.TryGetProperty(claimName, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var seconds) - ? DateTimeOffset.FromUnixTimeSeconds(seconds) - : (DateTimeOffset?)null); + TryReadPayload(accessToken, root => root.Num(claimName) is { } seconds ? DateTimeOffset.FromUnixTimeSeconds(seconds) : (DateTimeOffset?)null); static T? TryReadPayload(string accessToken, Func read) { var parts = accessToken.Split('.'); diff --git a/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs index b4d032b3ff..4781a85f78 100644 --- a/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs +++ b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs @@ -77,9 +77,19 @@ public static async ValueTask ConnectWebSocketAsync( } internal static string Describe(string method, Uri? uri, string? errorCode, string? bearer, DateTimeOffset now) => - $"401 from {method} {uri?.AbsolutePath}: server error={errorCode ?? "-"}; " + $"401 from {method} {uri?.AbsolutePath}: server error={Printable(errorCode ?? "-")}; " + $"{(bearer is null ? "no bearer sent" : DescribeBearer(bearer, now))}; local_now={now:o}"; + // The error code and the bearer's subject come from outside; a control character would let either + // forge what reads as a further log line. + internal static string Printable(string value) { + const int max = 200; + + var clipped = value.Length > max ? value[..max] + "…" : value; + + return clipped.Any(char.IsControl) ? string.Concat(clipped.Select(c => char.IsControl(c) ? '?' : c)) : clipped; + } + static string DescribeBearer(string token, DateTimeOffset now) { var exp = JwtClaims.TryGetTime(token, "exp"); var iat = JwtClaims.TryGetTime(token, "iat"); @@ -89,7 +99,7 @@ static string DescribeBearer(string token, DateTimeOffset now) { var expiresIn = exp is { } e ? $" ({(long)(e - now).TotalSeconds}s from now)" : ""; - return $"bearer sub={sub ?? "-"} iat={iat?.ToString("o") ?? "-"} exp={exp?.ToString("o") ?? "-"}{expiresIn}"; + return $"bearer sub={Printable(sub ?? "-")} iat={iat?.ToString("o") ?? "-"} exp={exp?.ToString("o") ?? "-"}{expiresIn}"; } // The server answers a refused bearer with {"error": "...", "message": "..."}. Reads at most one byte @@ -126,11 +136,7 @@ static string DescribeBearer(string token, DateTimeOffset now) { try { using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(head, 0, read)); - return doc.RootElement.ValueKind == JsonValueKind.Object - && doc.RootElement.TryGetProperty("error", out var error) - && error.ValueKind == JsonValueKind.String - ? error.GetString() - : null; + return doc.RootElement.Str("error"); } catch (JsonException) { return null; } diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs index 74ec28d140..e085697ef0 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs @@ -127,4 +127,17 @@ static string B64Url(string s) => await Assert.That(reports).Count().IsEqualTo(1); await Assert.That(reports[0]).Contains("bearer is not a readable JWT"); } + + [Test] + public async Task Control_characters_from_the_server_or_the_bearer_cannot_break_the_line() { + var token = Token(exp: Now.AddMinutes(5).ToUnixTimeSeconds(), iat: Now.ToUnixTimeSeconds(), sub: "user_1\\nFAKE line"); + + var (_, reports) = await SendAsync( + HttpStatusCode.Unauthorized, """{"error":"invalid_token\r\nFAKE: granted"}""", token); + + await Assert.That(reports[0]).DoesNotContain("\n"); + await Assert.That(reports[0]).DoesNotContain("\r"); + await Assert.That(reports[0]).Contains("server error=invalid_token??FAKE: granted"); + await Assert.That(reports[0]).Contains("sub=user_1?FAKE line"); + } } diff --git a/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs b/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs index d2600f3fda..d3e9e4ee4c 100644 --- a/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs @@ -43,9 +43,7 @@ public async Task A_refused_negotiate_is_reported_with_the_bearer_signalr_sent() try { await hub.StartAsync(new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token); - } catch (Exception) { - // Refused at negotiate, as stubbed. - } + } catch (Exception) { } await Assert.That(reports).Count().IsEqualTo(1); await Assert.That(reports[0]).Contains("server error=invalid_token; bearer sub=user_hub"); @@ -74,9 +72,7 @@ public async Task A_refused_websocket_upgrade_is_reported_with_the_bearer_sent() try { await hub.StartAsync(new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token); - } catch (Exception) { - // Refused at the upgrade, as stubbed. - } + } catch (Exception) { } await Assert.That(reports).Count().IsEqualTo(1); await Assert.That(reports[0]).Contains("401 from GET /hubs/sessions: server error=-; bearer sub=user_ws"); From c5107cd90061afdb81a0a670c6067f1b8f7ca99a Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:53:16 -0400 Subject: [PATCH 5/7] Read the refused upgrade's error from its WWW-Authenticate challenge Co-Authored-By: Claude Opus 5.5 --- .../Http/BearerRejectionReportHandler.cs | 27 +++++++++++++++++-- .../Http/BearerRejectionReportHandlerTests.cs | 8 ++++++ .../HubBearerRejectionReportTests.cs | 4 +-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs index 4781a85f78..4bdfd1e10b 100644 --- a/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs +++ b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs @@ -26,7 +26,8 @@ protected override async Task SendAsync(HttpRequestMessage : null; try { - var errorCode = await ReadErrorCodeAsync(response, ct); + var errorCode = await ReadErrorCodeAsync(response, ct) + ?? ChallengeError(response.Headers.WwwAuthenticate.FirstOrDefault()?.ToString()); report(Describe(request.Method.Method, request.RequestUri, errorCode, bearer, time.GetUtcNow())); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -60,7 +61,11 @@ public static async ValueTask ConnectWebSocketAsync( } catch (WebSocketException) { if (webSocket.HttpStatusCode == HttpStatusCode.Unauthorized) { try { - report(Describe("GET", uri, errorCode: null, string.IsNullOrWhiteSpace(bearer) ? null : bearer, time.GetUtcNow())); + var challenge = webSocket.HttpResponseHeaders?.TryGetValue("WWW-Authenticate", out var values) == true + ? values.FirstOrDefault() + : null; + + report(Describe("GET", uri, ChallengeError(challenge), string.IsNullOrWhiteSpace(bearer) ? null : bearer, time.GetUtcNow())); } catch { // A diagnostic must never change the exception the caller sees. } @@ -76,6 +81,24 @@ public static async ValueTask ConnectWebSocketAsync( } } + // The server names the error in an RFC 6750 challenge too: `Bearer error="invalid_token"`. It is the + // only place a refused WebSocket upgrade carries it, since the client never sees that response's body. + internal static string? ChallengeError(string? challenge) { + if (challenge is null) return null; + + const string marker = "error=\""; + + var start = challenge.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + + if (start < 0) return null; + + start += marker.Length; + + var end = challenge.IndexOf('"', start); + + return end > start ? challenge[start..end] : null; + } + internal static string Describe(string method, Uri? uri, string? errorCode, string? bearer, DateTimeOffset now) => $"401 from {method} {uri?.AbsolutePath}: server error={Printable(errorCode ?? "-")}; " + $"{(bearer is null ? "no bearer sent" : DescribeBearer(bearer, now))}; local_now={now:o}"; diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs index e085697ef0..9b88a270ab 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs @@ -140,4 +140,12 @@ public async Task Control_characters_from_the_server_or_the_bearer_cannot_break_ await Assert.That(reports[0]).Contains("server error=invalid_token??FAKE: granted"); await Assert.That(reports[0]).Contains("sub=user_1?FAKE line"); } + + [Test] + [Arguments("Bearer error=\"invalid_token\"", "invalid_token")] + [Arguments("Bearer realm=\"kcap\", error=\"token_expired\", error_description=\"x\"", "token_expired")] + [Arguments("Bearer", null)] + [Arguments(null, null)] + public async Task The_challenge_header_yields_its_error(string? challenge, string? error) => + await Assert.That(BearerRejectionReportHandler.ChallengeError(challenge)).IsEqualTo(error); } diff --git a/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs b/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs index d3e9e4ee4c..47528295c5 100644 --- a/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/HubBearerRejectionReportTests.cs @@ -55,7 +55,7 @@ public async Task A_refused_websocket_upgrade_is_reported_with_the_bearer_sent() .RespondWith(Response.Create().WithStatusCode(200).WithHeader("Content-Type", "application/json") .WithBody("""{"negotiateVersion":1,"connectionId":"c1","connectionToken":"t1","availableTransports":[{"transport":"WebSockets","transferFormats":["Text","Binary"]}]}""")); _server.Given(Request.Create().WithPath("/hubs/sessions").UsingGet()) - .RespondWith(Response.Create().WithStatusCode(401)); + .RespondWith(Response.Create().WithStatusCode(401).WithHeader("WWW-Authenticate", "Bearer error=\"token_expired\"")); var reports = new List(); void Report(string r) { lock (reports) reports.Add(r); } @@ -75,7 +75,7 @@ public async Task A_refused_websocket_upgrade_is_reported_with_the_bearer_sent() } catch (Exception) { } await Assert.That(reports).Count().IsEqualTo(1); - await Assert.That(reports[0]).Contains("401 from GET /hubs/sessions: server error=-; bearer sub=user_ws"); + await Assert.That(reports[0]).Contains("401 from GET /hubs/sessions: server error=token_expired; bearer sub=user_ws"); var upgrade = _server.LogEntries.Single(e => e.RequestMessage.Method == "GET"); From d27bd545b0d610c9e9fee9e63404b14f93701def Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:02:41 -0400 Subject: [PATCH 6/7] Take the error only from a Bearer challenge, whichever position it holds Co-Authored-By: Claude Opus 5.5 --- .../Http/BearerRejectionReportHandler.cs | 35 +++++++++++-------- .../Http/BearerRejectionReportHandlerTests.cs | 13 +++---- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs index 4bdfd1e10b..8e6ffa1059 100644 --- a/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs +++ b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.WebSockets; using System.Text; using System.Text.Json; @@ -27,7 +28,7 @@ protected override async Task SendAsync(HttpRequestMessage try { var errorCode = await ReadErrorCodeAsync(response, ct) - ?? ChallengeError(response.Headers.WwwAuthenticate.FirstOrDefault()?.ToString()); + ?? ChallengeError(response.Headers.WwwAuthenticate.Select(c => c.ToString())); report(Describe(request.Method.Method, request.RequestUri, errorCode, bearer, time.GetUtcNow())); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -61,11 +62,11 @@ public static async ValueTask ConnectWebSocketAsync( } catch (WebSocketException) { if (webSocket.HttpStatusCode == HttpStatusCode.Unauthorized) { try { - var challenge = webSocket.HttpResponseHeaders?.TryGetValue("WWW-Authenticate", out var values) == true - ? values.FirstOrDefault() + var challenges = webSocket.HttpResponseHeaders?.TryGetValue("WWW-Authenticate", out var values) == true + ? values : null; - report(Describe("GET", uri, ChallengeError(challenge), string.IsNullOrWhiteSpace(bearer) ? null : bearer, time.GetUtcNow())); + report(Describe("GET", uri, ChallengeError(challenges), string.IsNullOrWhiteSpace(bearer) ? null : bearer, time.GetUtcNow())); } catch { // A diagnostic must never change the exception the caller sees. } @@ -83,20 +84,24 @@ public static async ValueTask ConnectWebSocketAsync( // The server names the error in an RFC 6750 challenge too: `Bearer error="invalid_token"`. It is the // only place a refused WebSocket upgrade carries it, since the client never sees that response's body. - internal static string? ChallengeError(string? challenge) { - if (challenge is null) return null; - - const string marker = "error=\""; - - var start = challenge.IndexOf(marker, StringComparison.OrdinalIgnoreCase); - - if (start < 0) return null; + // Only a Bearer challenge's error describes the refused bearer; another scheme's is ignored. + internal static string? ChallengeError(IEnumerable? challenges) { + foreach (var challenge in challenges ?? []) { + if (!AuthenticationHeaderValue.TryParse(challenge, out var parsed) + || !parsed.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase) + || parsed.Parameter is not { } parameters) { + continue; + } - start += marker.Length; + foreach (var part in parameters.Split(',')) { + var pair = part.Split('=', 2, StringSplitOptions.TrimEntries); - var end = challenge.IndexOf('"', start); + if (pair.Length == 2 && pair[0].Equals("error", StringComparison.OrdinalIgnoreCase)) + return pair[1].Trim('"') is { Length: > 0 } error ? error : null; + } + } - return end > start ? challenge[start..end] : null; + return null; } internal static string Describe(string method, Uri? uri, string? errorCode, string? bearer, DateTimeOffset now) => diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs index 9b88a270ab..4cc85d6c0e 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs @@ -142,10 +142,11 @@ public async Task Control_characters_from_the_server_or_the_bearer_cannot_break_ } [Test] - [Arguments("Bearer error=\"invalid_token\"", "invalid_token")] - [Arguments("Bearer realm=\"kcap\", error=\"token_expired\", error_description=\"x\"", "token_expired")] - [Arguments("Bearer", null)] - [Arguments(null, null)] - public async Task The_challenge_header_yields_its_error(string? challenge, string? error) => - await Assert.That(BearerRejectionReportHandler.ChallengeError(challenge)).IsEqualTo(error); + [Arguments(new[] { "Bearer error=\"invalid_token\"" }, "invalid_token")] + [Arguments(new[] { "Bearer realm=\"kcap\", error=\"token_expired\", error_description=\"x\"" }, "token_expired")] + [Arguments(new[] { "Basic realm=\"x\"", "Bearer error=\"invalid_token\"" }, "invalid_token")] + [Arguments(new[] { "Custom error=\"not_ours\"" }, null)] + [Arguments(new[] { "Bearer" }, null)] + public async Task The_bearer_challenge_yields_its_error(string[] challenges, string? error) => + await Assert.That(BearerRejectionReportHandler.ChallengeError(challenges)).IsEqualTo(error); } From d0b941e339b37cd0faf24aa5c77377a19cd21c4c Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:07:43 -0400 Subject: [PATCH 7/7] Parse Bearer challenge parameters around quoted values Co-Authored-By: Claude Opus 5.5 --- .../Http/BearerRejectionReportHandler.cs | 45 ++++++++++++++++--- .../Http/BearerRejectionReportHandlerTests.cs | 2 + 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs index 8e6ffa1059..44af423429 100644 --- a/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs +++ b/src/Capacitor.Cli.Core/Http/BearerRejectionReportHandler.cs @@ -93,17 +93,52 @@ public static async ValueTask ConnectWebSocketAsync( continue; } - foreach (var part in parameters.Split(',')) { - var pair = part.Split('=', 2, StringSplitOptions.TrimEntries); - - if (pair.Length == 2 && pair[0].Equals("error", StringComparison.OrdinalIgnoreCase)) - return pair[1].Trim('"') is { Length: > 0 } error ? error : null; + foreach (var (name, value) in AuthParams(parameters)) { + if (name.Equals("error", StringComparison.OrdinalIgnoreCase)) return value.Length > 0 ? value : null; } } return null; } + // RFC 7235 auth-params: `name=token` or `name="quoted, \"escaped\" text"`, comma-separated. A comma or + // `error=` inside a quoted value belongs to that value and must not start a parameter of its own. + static IEnumerable<(string Name, string Value)> AuthParams(string parameters) { + var i = 0; + + while (i < parameters.Length) { + while (i < parameters.Length && (parameters[i] == ',' || char.IsWhiteSpace(parameters[i]))) i++; + + var nameStart = i; + + while (i < parameters.Length && parameters[i] != '=' && parameters[i] != ',') i++; + + var name = parameters[nameStart..i].Trim(); + + if (i >= parameters.Length || parameters[i] != '=') continue; + + i++; + + while (i < parameters.Length && char.IsWhiteSpace(parameters[i])) i++; + + var value = new StringBuilder(); + + if (i < parameters.Length && parameters[i] == '"') { + for (i++; i < parameters.Length && parameters[i] != '"'; i++) { + if (parameters[i] == '\\' && i + 1 < parameters.Length) i++; + + value.Append(parameters[i]); + } + + i++; + } else { + while (i < parameters.Length && parameters[i] != ',') value.Append(parameters[i++]); + } + + yield return (name, value.ToString().Trim()); + } + } + internal static string Describe(string method, Uri? uri, string? errorCode, string? bearer, DateTimeOffset now) => $"401 from {method} {uri?.AbsolutePath}: server error={Printable(errorCode ?? "-")}; " + $"{(bearer is null ? "no bearer sent" : DescribeBearer(bearer, now))}; local_now={now:o}"; diff --git a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs index 4cc85d6c0e..b20e49da19 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/Http/BearerRejectionReportHandlerTests.cs @@ -146,6 +146,8 @@ public async Task Control_characters_from_the_server_or_the_bearer_cannot_break_ [Arguments(new[] { "Bearer realm=\"kcap\", error=\"token_expired\", error_description=\"x\"" }, "token_expired")] [Arguments(new[] { "Basic realm=\"x\"", "Bearer error=\"invalid_token\"" }, "invalid_token")] [Arguments(new[] { "Custom error=\"not_ours\"" }, null)] + [Arguments(new[] { "Bearer error_description=\"detail, error=fake\", error=\"invalid_token\"" }, "invalid_token")] + [Arguments(new[] { "Bearer error_description=\"a \\\"quoted, error=fake\\\" b\", error=token_expired" }, "token_expired")] [Arguments(new[] { "Bearer" }, null)] public async Task The_bearer_challenge_yields_its_error(string[] challenges, string? error) => await Assert.That(BearerRejectionReportHandler.ChallengeError(challenges)).IsEqualTo(error);