fix: SameSite=Lax for auth cookie + bidirectional identity linking
- Change cookie auth SameSite from Strict to Lax so Discord OAuth callback can see existing Telegram auth session and perform linking instead of creating a new standalone Discord session (root cause of broken linking). - Add linking logic to /auth/telegram endpoint for Discord→Telegram linking. - Add Telegram Login Widget in Profile.razor for Discord users. - Add CookieAuthOptionsTests to verify Lax SameSite configuration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
@page "/profile"
|
@page "/profile"
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.Extensions.Configuration
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject ISessionStore SessionStore
|
@inject ISessionStore SessionStore
|
||||||
|
@inject IConfiguration Configuration
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
<PageTitle>Профиль — GM-Relay</PageTitle>
|
<PageTitle>Профиль — GM-Relay</PageTitle>
|
||||||
|
|
||||||
@@ -61,6 +64,19 @@
|
|||||||
{
|
{
|
||||||
<p class="muted-text">Discord уже привязан.</p>
|
<p class="muted-text">Discord уже привязан.</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (currentPlatform == "Discord" && !HasLinkedPlatform("Telegram"))
|
||||||
|
{
|
||||||
|
var botUsername = Configuration["Telegram__BotUsername"] ?? Configuration["Telegram:BotUsername"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(botUsername))
|
||||||
|
{
|
||||||
|
var authUrl = new Uri(new Uri(Navigation.BaseUri), "auth/telegram").ToString();
|
||||||
|
var widgetHtml = $"<script async src=\"https://telegram.org/js/telegram-widget.js?22\" data-telegram-login=\"{botUsername}\" data-size=\"large\" data-auth-url=\"{authUrl}\" data-request-access=\"write\"></script>";
|
||||||
|
<div class="telegram-widget-wrapper">
|
||||||
|
@((MarkupString)widgetHtml)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(errorMessage))
|
@if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||||
|
|||||||
+29
-10
@@ -61,7 +61,7 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
|||||||
options.AccessDeniedPath = "/access-denied";
|
options.AccessDeniedPath = "/access-denied";
|
||||||
options.Cookie.HttpOnly = true;
|
options.Cookie.HttpOnly = true;
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||||
options.SlidingExpiration = true;
|
options.SlidingExpiration = true;
|
||||||
});
|
});
|
||||||
@@ -123,19 +123,38 @@ app.MapHealthChecks("/alive", new HealthCheckOptions
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Endpoint to handle Telegram Login callback
|
// Endpoint to handle Telegram Login callback
|
||||||
app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService authService) =>
|
app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService authService, ISessionStore sessionStore) =>
|
||||||
{
|
{
|
||||||
if (authService.Verify(context.Request.Query, out var telegramId, out var name))
|
if (!authService.Verify(context.Request.Query, out var telegramId, out var name))
|
||||||
|
return Results.Redirect("/login?error=auth_failed");
|
||||||
|
|
||||||
|
await sessionStore.UpsertPlayerAsync("Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture), name, null);
|
||||||
|
|
||||||
|
// If already authenticated via another platform, link instead of replacing session
|
||||||
|
if (context.User.Identity?.IsAuthenticated == true
|
||||||
|
&& context.User.TryGetPlatformIdentity(out var currentPlatform, out var currentExternalUserId)
|
||||||
|
&& currentPlatform != "Telegram")
|
||||||
{
|
{
|
||||||
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
try
|
||||||
await context.SignInAsync(
|
{
|
||||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
await sessionStore.LinkIdentityAsync(
|
||||||
CreateTelegramPrincipal(telegramId, name),
|
currentPlatform, currentExternalUserId,
|
||||||
authProperties);
|
"Telegram", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
return Results.Redirect("/");
|
name);
|
||||||
|
return Results.Redirect("/profile?linked=telegram");
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Results.Redirect($"/profile?link_error={Uri.EscapeDataString(ex.Message)}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Redirect("/login?error=auth_failed");
|
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
||||||
|
await context.SignInAsync(
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
CreateTelegramPrincipal(telegramId, name),
|
||||||
|
authProperties);
|
||||||
|
return Results.Redirect("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/auth/telegram-webapp", async (
|
app.MapPost("/auth/telegram-webapp", async (
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class CookieAuthOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CookieAuthOptions_ShouldUseLaxSameSite_ToAllowOAuthCallback()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie(options =>
|
||||||
|
{
|
||||||
|
options.Cookie.HttpOnly = true;
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
|
||||||
|
var options = optionsMonitor.Get(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(SameSiteMode.Lax, options.Cookie.SameSite);
|
||||||
|
Assert.True(options.Cookie.HttpOnly);
|
||||||
|
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user