From bfed400b4da5d55313be6557a9162a18f3f18559 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Mon, 25 May 2026 11:02:13 +0300 Subject: [PATCH] feat(web): add Discord OAuth service and authorization endpoints - DiscordOAuthOptions for client_id, secret, redirect_uri - DiscordAuthService exchanges code for token and fetches user profile - /auth/discord and /auth/discord/callback endpoints - CreateDiscordPrincipal for cookie auth claims - Telegram principal now includes Platform claim for forward compatibility Co-Authored-By: Claude Opus 4.7 --- src/GmRelay.Web/DiscordOAuthOptions.cs | 19 +++++ src/GmRelay.Web/Program.cs | 60 +++++++++++++- .../Services/DiscordAuthService.cs | 82 +++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/GmRelay.Web/DiscordOAuthOptions.cs create mode 100644 src/GmRelay.Web/Services/DiscordAuthService.cs diff --git a/src/GmRelay.Web/DiscordOAuthOptions.cs b/src/GmRelay.Web/DiscordOAuthOptions.cs new file mode 100644 index 0000000..46e0073 --- /dev/null +++ b/src/GmRelay.Web/DiscordOAuthOptions.cs @@ -0,0 +1,19 @@ +namespace GmRelay.Web; + +public sealed class DiscordOAuthOptions +{ + public required string ClientId { get; set; } + public required string ClientSecret { get; set; } + public required string RedirectUri { get; set; } + public string[] Scopes { get; set; } = ["identify", "guilds"]; + + public void Validate() + { + if (string.IsNullOrWhiteSpace(ClientId)) + throw new InvalidOperationException("Discord:ClientId is required."); + if (string.IsNullOrWhiteSpace(ClientSecret)) + throw new InvalidOperationException("Discord:ClientSecret is required."); + if (string.IsNullOrWhiteSpace(RedirectUri)) + throw new InvalidOperationException("Discord:RedirectUri is required."); + } +} diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index f09b076..4ac6034 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -16,6 +16,12 @@ var builder = WebApplication.CreateBuilder(args); // Add Aspire service defaults builder.AddServiceDefaults(); +// Add HttpClient +builder.Services.AddHttpClient(); + +// Add HttpContextAccessor for platform-agnostic identity resolution +builder.Services.AddHttpContextAccessor(); + // Add health checks builder.Services.AddHealthChecks() .AddCheck("npgsql"); @@ -29,6 +35,8 @@ builder.AddNpgsqlDataSource("gmrelaydb"); // Add Services builder.Services.AddSingleton(); +builder.Services.Configure(builder.Configuration.GetSection("Discord")); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -174,6 +182,38 @@ app.MapPost("/auth/logout", async (HttpContext context) => return Results.Redirect("/"); }); +// Discord OAuth endpoints +app.MapGet("/auth/discord", (DiscordAuthService discordAuth) => +{ + var state = Guid.NewGuid().ToString("N"); + var url = discordAuth.BuildAuthorizeUrl(state); + return Results.Redirect(url); +}); + +app.MapGet("/auth/discord/callback", async ( + HttpContext context, + DiscordAuthService discordAuth, + ISessionStore sessionStore) => +{ + var code = context.Request.Query["code"].ToString(); + if (string.IsNullOrWhiteSpace(code)) + return Results.Redirect("/login?error=auth_failed"); + + var user = await discordAuth.ExchangeCodeAsync(code); + if (user is null) + return Results.Redirect("/login?error=auth_failed"); + + await sessionStore.UpsertDiscordUserAsync(user.Id, user.DisplayName, user.AvatarUrl); + + var authProperties = new AuthenticationProperties { IsPersistent = true }; + await context.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + CreateDiscordPrincipal(user.Id, user.DisplayName, user.AvatarUrl), + authProperties); + + return Results.Redirect("/"); +}); + // Public calendar subscription endpoint (no auth required) app.MapGet("/calendar/{token}.ics", async ( string token, @@ -200,11 +240,29 @@ static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name) { new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)), new(ClaimTypes.Name, name), - new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)) + new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)), + new("Platform", "Telegram") }; var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); return new ClaimsPrincipal(claimsIdentity); } +static ClaimsPrincipal CreateDiscordPrincipal(string discordId, string name, string? avatarUrl) +{ + var claims = new List + { + new(ClaimTypes.NameIdentifier, discordId), + new(ClaimTypes.Name, name), + new("DiscordId", discordId), + new("Platform", "Discord") + }; + + if (!string.IsNullOrWhiteSpace(avatarUrl)) + claims.Add(new Claim("AvatarUrl", avatarUrl)); + + var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + return new ClaimsPrincipal(claimsIdentity); +} + public sealed record TelegramWebAppAuthRequest(string InitData); diff --git a/src/GmRelay.Web/Services/DiscordAuthService.cs b/src/GmRelay.Web/Services/DiscordAuthService.cs new file mode 100644 index 0000000..6733c2c --- /dev/null +++ b/src/GmRelay.Web/Services/DiscordAuthService.cs @@ -0,0 +1,82 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GmRelay.Web.Services; + +public sealed class DiscordAuthService(IHttpClientFactory httpClientFactory, IConfiguration configuration) +{ + private readonly DiscordOAuthOptions _options = configuration.GetSection("Discord").Get() ?? new DiscordOAuthOptions(); + + public string BuildAuthorizeUrl(string state) + { + _options.Validate(); + var scopes = string.Join(" ", _options.Scopes); + return $"https://discord.com/oauth2/authorize?client_id={_options.ClientId}&redirect_uri={Uri.EscapeDataString(_options.RedirectUri)}&response_type=code&scope={Uri.EscapeDataString(scopes)}&state={Uri.EscapeDataString(state)}"; + } + + public async Task ExchangeCodeAsync(string code) + { + _options.Validate(); + var client = httpClientFactory.CreateClient(); + + var tokenResponse = await ExchangeCodeForTokenAsync(client, code); + if (tokenResponse is null) + return null; + + return await FetchUserProfileAsync(client, tokenResponse.AccessToken); + } + + private async Task ExchangeCodeForTokenAsync(HttpClient client, string code) + { + var content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = _options.RedirectUri, + ["client_id"] = _options.ClientId, + ["client_secret"] = _options.ClientSecret + }); + + var response = await client.PostAsync("https://discord.com/api/oauth2/token", content); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json); + } + + private static async Task FetchUserProfileAsync(HttpClient client, string accessToken) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + var response = await client.GetAsync("https://discord.com/api/users/@me"); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json); + } +} + +public sealed record DiscordTokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("token_type")] string TokenType, + [property: JsonPropertyName("expires_in")] int ExpiresIn, + [property: JsonPropertyName("scope")] string Scope); + +public sealed record DiscordUser( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("username")] string Username, + [property: JsonPropertyName("discriminator")] string Discriminator, + [property: JsonPropertyName("avatar")] string? Avatar, + [property: JsonPropertyName("email")] string? Email) +{ + public string DisplayName => + Discriminator == "0" ? Username : $"{Username}#{Discriminator}"; + + public string? AvatarUrl => + !string.IsNullOrWhiteSpace(Avatar) + ? $"https://cdn.discordapp.com/avatars/{Id}/{Avatar}.png" + : null; +}