fix: replace cookie-based Discord OAuth CSRF with server-side state store
- 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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user