fix: replace cookie-based Discord OAuth CSRF with server-side state store
Deploy Telegram Bot / build-and-push (push) Successful in 4m19s
Deploy Telegram Bot / scan-images (push) Successful in 1m24s
Deploy Telegram Bot / deploy (push) Successful in 11s

- Replace __DiscordOAuthState cookie (blocked by third-party cookie policies)
  with in-memory DiscordOAuthStateStore singleton
- State is created server-side and validated on callback, eliminating
  cross-site cookie transmission issues entirely
- Removed CryptographicOperations dependency from Program.cs

Bump version → 2.8.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 13:18:23 +03:00
parent dd0828a63d
commit 7a2ed808c4
2 changed files with 38 additions and 19 deletions
+6 -19
View File
@@ -39,6 +39,7 @@ builder.AddNpgsqlDataSource("gmrelaydb");
builder.Services.AddSingleton<TelegramAuthService>(); builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord")); builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
builder.Services.AddSingleton<DiscordAuthService>(); builder.Services.AddSingleton<DiscordAuthService>();
builder.Services.AddSingleton<DiscordOAuthStateStore>();
builder.Services.AddSingleton<ISessionStore, SessionService>(); builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>(); builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<CalendarSubscriptionService>(); builder.Services.AddScoped<CalendarSubscriptionService>();
@@ -185,16 +186,9 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
}); });
// Discord OAuth endpoints // Discord OAuth endpoints
app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth) => app.MapGet("/auth/discord", (DiscordAuthService discordAuth, DiscordOAuthStateStore stateStore) =>
{ {
var state = Guid.NewGuid().ToString("N"); var state = stateStore.CreateState();
context.Response.Cookies.Append("__DiscordOAuthState", state, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.None,
MaxAge = TimeSpan.FromMinutes(5)
});
var url = discordAuth.BuildAuthorizeUrl(state); var url = discordAuth.BuildAuthorizeUrl(state);
return Results.Redirect(url); return Results.Redirect(url);
}); });
@@ -202,23 +196,16 @@ app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth
app.MapGet("/auth/discord/callback", async ( app.MapGet("/auth/discord/callback", async (
HttpContext context, HttpContext context,
DiscordAuthService discordAuth, DiscordAuthService discordAuth,
ISessionStore sessionStore, DiscordOAuthStateStore stateStore,
ILogger<Program> logger) => ISessionStore sessionStore) =>
{ {
var code = context.Request.Query["code"].ToString(); var code = context.Request.Query["code"].ToString();
var state = context.Request.Query["state"].ToString(); var state = context.Request.Query["state"].ToString();
var storedState = context.Request.Cookies["__DiscordOAuthState"];
context.Response.Cookies.Delete("__DiscordOAuthState");
if (string.IsNullOrWhiteSpace(code) || if (string.IsNullOrWhiteSpace(code) ||
string.IsNullOrWhiteSpace(state) || string.IsNullOrWhiteSpace(state) ||
!CryptographicOperations.FixedTimeEquals( !stateStore.ValidateAndRemove(state))
System.Text.Encoding.UTF8.GetBytes(state),
System.Text.Encoding.UTF8.GetBytes(storedState ?? string.Empty)))
{ {
logger.LogWarning("Discord OAuth CSRF validation failed. code_present={CodePresent}, state_present={StatePresent}, stored_state_present={StoredStatePresent}",
!string.IsNullOrWhiteSpace(code), !string.IsNullOrWhiteSpace(state), !string.IsNullOrWhiteSpace(storedState));
return Results.Redirect("/login?error=auth_failed"); return Results.Redirect("/login?error=auth_failed");
} }
@@ -0,0 +1,32 @@
namespace GmRelay.Web.Services;
public sealed class DiscordOAuthStateStore(ILogger<DiscordOAuthStateStore> logger)
{
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _states = new();
public string CreateState()
{
var state = Guid.NewGuid().ToString("N");
_states[state] = DateTime.UtcNow.AddMinutes(5);
logger.LogDebug("Discord OAuth state created: {State}", state);
return state;
}
public bool ValidateAndRemove(string state)
{
if (!_states.TryRemove(state, out var expiresAt))
{
logger.LogWarning("Discord OAuth state not found or already used: {State}", state);
return false;
}
if (DateTime.UtcNow > expiresAt)
{
logger.LogWarning("Discord OAuth state expired: {State}", state);
return false;
}
logger.LogDebug("Discord OAuth state validated: {State}", state);
return true;
}
}