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()
{