From c085cead9d13b7e8d9249def4bd5e10428e9130a Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 14:26:14 +0000 Subject: [PATCH] Heimdall start refusal: log the HTTP status and Heimdall's error code The journal said only 'Heimdall rejected the auth start', which cannot tell a wrong app secret (401 app_auth_required) from a fault. The message now carries the status and, when the body has one, an error code that is a short snake_case token; no other body text is echoed. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Heimdall/HeimdallAuthService.cs | 28 ++++++++++++++++++- .../HeimdallCompletionTests.cs | 24 +++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 8d10b53..e3abda2 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -53,6 +53,32 @@ public sealed class HeimdallAuthService( } } + /// + /// Names the HTTP status and, when the body carries one, Heimdall's error code. The code is shown only + /// if it is a short snake_case token, so nothing else from the body (free text, tokens) reaches a log. + /// + private static async Task RefusedStartMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var detail = $"HTTP {(int)response.StatusCode}"; + try + { + var body = await response.Content.ReadFromJsonAsync(cancellationToken); + if (body.ValueKind == JsonValueKind.Object + && body.TryGetProperty("error", out var error) + && error.ValueKind == JsonValueKind.String + && error.GetString() is { Length: > 0 and <= 64 } code + && code.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) + { + detail += ", " + code; + } + } + catch (JsonException) + { + } + + return $"Heimdall rejected the auth start ({detail})."; + } + public async Task StartAsync( string provider, string callbackBaseUrl, @@ -91,7 +117,7 @@ public sealed class HeimdallAuthService( if (!response.IsSuccessStatusCode) { - throw new HeimdallAuthException("Heimdall rejected the auth start."); + throw new HeimdallAuthException(await RefusedStartMessageAsync(response, cancellationToken)); } var payload = await response.Content.ReadFromJsonAsync(cancellationToken); diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index afcfcba..f7e40de 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -1115,7 +1115,7 @@ public sealed class HeimdallCompletionTests } [Theory] - [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start.")] + [InlineData(HttpStatusCode.InternalServerError, "{}", "Heimdall rejected the auth start (HTTP 500).")] [InlineData(HttpStatusCode.OK, "{}", "Heimdall auth start returned no authorization URL.")] [InlineData(HttpStatusCode.OK, "{\"authorizationUrl\":\" \"}", "Heimdall auth start returned no authorization URL.")] public async Task A_start_Heimdall_cannot_serve_issues_no_cookie_and_a_fixed_refusal(HttpStatusCode status, string body, string reason) @@ -1132,6 +1132,28 @@ public sealed class HeimdallCompletionTests Assert.Contains("Heimdall start refused: " + reason, string.Join("\n", host.Logs.Lines)); } + [Theory] + [InlineData(HttpStatusCode.Unauthorized, """{"error":"app_auth_required"}""", "Heimdall rejected the auth start (HTTP 401, app_auth_required).")] + [InlineData(HttpStatusCode.BadRequest, """{"error":"provider_not_supported_for_app","detail":"free text"}""", "Heimdall rejected the auth start (HTTP 400, provider_not_supported_for_app).")] + [InlineData(HttpStatusCode.Unauthorized, """{"error":"Not a code CANARY secret=abc"}""", "Heimdall rejected the auth start (HTTP 401).")] + [InlineData(HttpStatusCode.Unauthorized, """{"error":{"nested":"app_auth_required"}}""", "Heimdall rejected the auth start (HTTP 401).")] + [InlineData(HttpStatusCode.Unauthorized, "not json at all", "Heimdall rejected the auth start (HTTP 401).")] + public async Task A_refused_start_logs_the_status_and_a_well_formed_error_code_and_nothing_else_from_the_body( + HttpStatusCode status, string body, string reason) + { + using var host = new HeimdallHost(); + host.Heimdall.StartStatus = status; + host.Heimdall.StartBody = body; + + var attempt = await host.StartAsync(member: true); + + Assert.Equal(ErrorRedirect, attempt.Response.Headers.Location!.OriginalString); + var logs = string.Join("\n", host.Logs.Lines); + Assert.Contains("Heimdall start refused: " + reason, logs); + Assert.DoesNotContain("CANARY", logs); + Assert.DoesNotContain("free text", logs); + } + [Fact] public async Task Refusals_echo_no_input_in_responses_or_logs() {