Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLeitwolf112026-10-16 16:13:45 +0300
committerLeitwolf112026-10-16 16:13:45 +0300
commit031cbf7efc5d97b28259da5b387cb3a3dbd242d5 (patch)
tree44077b255fdf019e82d6960ba30e70c3a59a954a
parent8f58831373b72dc5b60bad649a961acd0bb94aa4 (diff)
downloadprogmaweb-031cbf7efc5d97b28259da5b387cb3a3dbd242d5.tar.gz
Log why a Google callback could not finish
A failed Google callback has returned the visitor to /login?google=failed since the previous release, but nothing recorded why: a declined consent, an expired or missing state cookie and a forged request all looked the same, and the log of the service held no entry at all. GoogleSignInFailure now handles OnRemoteFailure. It writes one warning, event GoogleSignInFailed (id 5), with the cause: the error code Google reported, such as access_denied, or the reason the handler refused the callback, such as "The oauth state was missing or invalid." An error code is written only when it is a short run of letters, digits, '_', '-' and '.'; anything else from the query string is written as an unrecognized error. The authorization code, the state and tokens are never logged. The redirect is unchanged. Four new tests cover the entry, the redirect, an unusual error value and a refused state. The account API document describes the log entry.
-rw-r--r--apps/api-tests/GoogleSignInFailureTests.cs65
-rw-r--r--apps/api/Accounts/GoogleSignInFailure.cs57
-rw-r--r--apps/api/Program.cs13
-rw-r--r--docs/ACCOUNT-API.md5
4 files changed, 133 insertions, 7 deletions
diff --git a/apps/api-tests/GoogleSignInFailureTests.cs b/apps/api-tests/GoogleSignInFailureTests.cs
new file mode 100644
index 0000000..7c44d5c
--- /dev/null
+++ b/apps/api-tests/GoogleSignInFailureTests.cs
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Google;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Progmasoft.Progmaweb.Api.Accounts;
+
+namespace Progmasoft.Progmaweb.Api.Tests;
+
+public sealed class GoogleSignInFailureTests
+{
+ private sealed class RecordingLogger : ILogger
+ {
+ public List<(LogLevel Level, EventId Event, string Message)> Entries { get; } = [];
+
+ public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func<TState, Exception?, string> formatter) => Entries.Add((logLevel, eventId, formatter(state, exception)));
+ }
+
+ private static RemoteFailureContext Failure(string query, Exception failure)
+ {
+ DefaultHttpContext http = new();
+ http.Request.QueryString = new QueryString(query);
+ AuthenticationScheme scheme = new(GoogleDefaults.AuthenticationScheme, null, typeof(GoogleHandler));
+ return new RemoteFailureContext(http, scheme, new GoogleOptions(), failure);
+ }
+
+ [Fact]
+ public async Task AFailedCallbackIsLoggedAndReturnsToTheSignInPage()
+ {
+ RecordingLogger logger = new();
+ RemoteFailureContext context = Failure(
+ "?state=secret-state&code=secret-code",
+ new AuthenticationFailureException(
+ "An error was encountered while handling the remote login.",
+ new AuthenticationFailureException("The oauth state was missing or invalid.")));
+
+ await GoogleSignInFailure.HandleAsync(context, logger);
+
+ Assert.Equal("/login?google=failed", context.Response.Headers.Location.ToString());
+ Assert.True(context.Result?.Handled);
+ (LogLevel level, EventId eventId, string message) = Assert.Single(logger.Entries);
+ Assert.Equal(LogLevel.Warning, level);
+ Assert.Equal("GoogleSignInFailed", eventId.Name);
+ Assert.Equal("Google sign-in could not be completed: The oauth state was missing or invalid.", message);
+ Assert.DoesNotContain("secret", message, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("?error=access_denied&state=s", "Google reported access_denied")]
+ [InlineData("?error=%3Cscript%3E&state=s", "Google reported an unrecognized error")]
+ [InlineData("?state=s", "the denied consent")]
+ public void TheReasonNamesWhatGoogleReportedOrWhyTheCallbackWasRefused(string query, string expected)
+ {
+ RemoteFailureContext context = Failure(query, new AuthenticationFailureException("the denied consent"));
+
+ Assert.Equal(expected, GoogleSignInFailure.Reason(context));
+ }
+}
diff --git a/apps/api/Accounts/GoogleSignInFailure.cs b/apps/api/Accounts/GoogleSignInFailure.cs
new file mode 100644
index 0000000..3a39f68
--- /dev/null
+++ b/apps/api/Accounts/GoogleSignInFailure.cs
@@ -0,0 +1,57 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using Microsoft.AspNetCore.Authentication;
+
+namespace Progmasoft.Progmaweb.Api.Accounts;
+
+/// <summary>Ends a Google callback that cannot finish: it records why and returns to the sign-in page.</summary>
+/// <remarks>
+/// The visitor sees one message for every cause, so the log is where the cause can be read. The entry names the
+/// error Google reported, such as <c>access_denied</c>, or the reason the handler refused the callback, such as a
+/// missing or invalid state. It never contains the authorization code, the state or a token.
+/// </remarks>
+internal static class GoogleSignInFailure
+{
+ /// <summary>The page that explains a failed Google sign-in.</summary>
+ public const string SignInPage = "/login?google=failed";
+
+ /// <summary>Writes the cause of a failed Google callback.</summary>
+ private static readonly Action<ILogger, string, Exception?> FailureReport = LoggerMessage.Define<string>(
+ LogLevel.Warning,
+ new EventId(5, "GoogleSignInFailed"),
+ "Google sign-in could not be completed: {Reason}");
+
+ /// <summary>The longest provider error code that is written as it was sent.</summary>
+ private const int MaximumErrorLength = 64;
+
+ /// <summary>Records the cause of the failure and redirects to <see cref="SignInPage"/>.</summary>
+ /// <param name="context">The failed remote authentication.</param>
+ /// <param name="logger">Receives the cause.</param>
+ /// <returns>A completed task; the response is handled.</returns>
+ public static Task HandleAsync(RemoteFailureContext context, ILogger logger)
+ {
+ FailureReport(logger, Reason(context), null);
+ context.Response.Redirect(SignInPage);
+ context.HandleResponse();
+ return Task.CompletedTask;
+ }
+
+ /// <summary>Describes why the callback failed, in words safe to log.</summary>
+ /// <param name="context">The failed remote authentication.</param>
+ /// <returns>The error Google reported, or the reason the handler refused the callback.</returns>
+ internal static string Reason(RemoteFailureContext context)
+ {
+ string? error = context.Request.Query["error"];
+ if (!string.IsNullOrEmpty(error))
+ {
+ // The value comes from the query string, so only a short code of safe characters is written as it is.
+ bool plain = error.Length <= MaximumErrorLength && error.All(character =>
+ char.IsAsciiLetterOrDigit(character) || character is '_' or '-' or '.');
+ return plain ? $"Google reported {error}" : "Google reported an unrecognized error";
+ }
+
+ string? message = context.Failure?.InnerException?.Message ?? context.Failure?.Message;
+ return string.IsNullOrWhiteSpace(message) ? "the callback could not be processed" : message;
+ }
+}
diff --git a/apps/api/Program.cs b/apps/api/Program.cs
index 688fe7d..31b46a2 100644
--- a/apps/api/Program.cs
+++ b/apps/api/Program.cs
@@ -45,13 +45,12 @@ if (!string.IsNullOrWhiteSpace(googleClientId) && !string.IsNullOrWhiteSpace(goo
options.SignInScheme = AccountEndpoints.ExternalCookieScheme;
options.SaveTokens = false;
// A callback that cannot finish (the visitor declined on Google, or the state cookie is missing or
- // expired) would otherwise end in an unhandled 500. The sign-in page explains a failed Google sign-in.
- options.Events.OnRemoteFailure = context =>
- {
- context.Response.Redirect("/login?google=failed");
- context.HandleResponse();
- return Task.CompletedTask;
- };
+ // expired) would otherwise end in an unhandled 500. The cause is logged, and the sign-in page explains
+ // a failed Google sign-in.
+ options.Events.OnRemoteFailure = context => GoogleSignInFailure.HandleAsync(
+ context,
+ context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
+ .CreateLogger(typeof(GoogleSignInFailure)));
});
}
builder.Services.AddProblemDetails();
diff --git a/docs/ACCOUNT-API.md b/docs/ACCOUNT-API.md
index 0bf05e9..68186b5 100644
--- a/docs/ACCOUNT-API.md
+++ b/docs/ACCOUNT-API.md
@@ -99,6 +99,11 @@ The flow is a server-side authorization-code flow. The browser is redirected; it
| the email address of the identity belongs to another account | redirect to `/login?google=failed` |
| Google reported no usable identity, or the callback failed | redirect to `/login?google=failed` |
+The visitor sees one message for every cause. When the callback itself fails, the service logs the cause as a
+warning with the event `GoogleSignInFailed` (event id 5): the error Google reported, such as `access_denied`, or the
+reason the callback was refused, such as a missing or invalid state. The entry never contains the authorization code,
+the state or a token.
+
A Google identity is never linked to an existing account by email address. Password registrations do not verify the
address, so linking by address could give one person's account to another.