From 6b0e5f919d9213c03f13b03d66ba80dd0c0a19be Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 22 Jun 2025 02:22:19 +0800 Subject: [PATCH] :sparkles: Add afdian as OIDC provider --- DysonNetwork.Sphere/Auth/AuthController.cs | 2 +- .../Auth/OpenId/AfdianOidcService.cs | 115 ++++++++++++++++++ .../Auth/OpenId/OidcController.cs | 1 + .../Startup/ServiceCollectionExtensions.cs | 2 + 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 DysonNetwork.Sphere/Auth/OpenId/AfdianOidcService.cs diff --git a/DysonNetwork.Sphere/Auth/AuthController.cs b/DysonNetwork.Sphere/Auth/AuthController.cs index 3fd885c..15b99bc 100644 --- a/DysonNetwork.Sphere/Auth/AuthController.cs +++ b/DysonNetwork.Sphere/Auth/AuthController.cs @@ -97,7 +97,7 @@ public class AuthController( .FirstOrDefaultAsync(); return challenge is null ? NotFound("Auth challenge was not found.") - : challenge.Account.AuthFactors.Where(e => e.EnabledAt != null).ToList(); + : challenge.Account.AuthFactors.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }).ToList(); } [HttpPost("challenge/{id:guid}/factors/{factorId:guid}")] diff --git a/DysonNetwork.Sphere/Auth/OpenId/AfdianOidcService.cs b/DysonNetwork.Sphere/Auth/OpenId/AfdianOidcService.cs new file mode 100644 index 0000000..f07c4f5 --- /dev/null +++ b/DysonNetwork.Sphere/Auth/OpenId/AfdianOidcService.cs @@ -0,0 +1,115 @@ +using System.Net.Http.Json; +using System.Text.Json; +using DysonNetwork.Sphere.Storage; + +namespace DysonNetwork.Sphere.Auth.OpenId; + +public class AfdianOidcService( + IConfiguration configuration, + IHttpClientFactory httpClientFactory, + AppDatabase db, + AuthService auth, + ICacheService cache +) + : OidcService(configuration, httpClientFactory, db, auth, cache) +{ + public override string ProviderName => "Afdian"; + protected override string DiscoveryEndpoint => ""; // Afdian doesn't have a standard OIDC discovery endpoint + protected override string ConfigSectionName => "Afdian"; + + public override string GetAuthorizationUrl(string state, string nonce) + { + var config = GetProviderConfig(); + var queryParams = new Dictionary + { + { "client_id", config.ClientId }, + { "redirect_uri", config.RedirectUri }, + { "response_type", "code" }, + { "scope", "basic" }, + { "state", state }, + }; + + var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}")); + return $"https://afdian.com/oauth2/authorize?{queryString}"; + } + + protected override Task GetDiscoveryDocumentAsync() + { + return Task.FromResult(new OidcDiscoveryDocument + { + AuthorizationEndpoint = "https://afdian.com/oauth2/authorize", + TokenEndpoint = "https://afdian.com/api/oauth2/access_token", + UserinfoEndpoint = null, + JwksUri = null + })!; + } + + public override async Task ProcessCallbackAsync(OidcCallbackData callbackData) + { + var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code); + if (tokenResponse?.AccessToken == null) + { + throw new InvalidOperationException("Failed to obtain access token from Afdian"); + } + + var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken); + + userInfo.AccessToken = tokenResponse.AccessToken; + userInfo.RefreshToken = tokenResponse.RefreshToken; + + return userInfo; + } + + protected override async Task ExchangeCodeForTokensAsync(string code, + string? codeVerifier = null) + { + var config = GetProviderConfig(); + var client = HttpClientFactory.CreateClient(); + + var content = new FormUrlEncodedContent(new Dictionary + { + { "client_id", config.ClientId }, + { "client_secret", config.ClientSecret }, + { "grant_type", "authorization_code" }, + { "code", code }, + { "redirect_uri", config.RedirectUri }, + }); + + var response = await client.PostAsync("https://afdian.com/api/oauth2/access_token", content); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadFromJsonAsync(); + } + + private async Task GetUserInfoAsync(string accessToken) + { + var client = HttpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me"); + request.Headers.Add("Authorization", $"Bearer {accessToken}"); + + var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var discordUser = JsonDocument.Parse(json).RootElement; + + var userId = discordUser.GetProperty("id").GetString() ?? ""; + var avatar = discordUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null; + + return new OidcUserInfo + { + UserId = userId, + Email = (discordUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null) ?? "", + EmailVerified = discordUser.TryGetProperty("verified", out var verifiedElement) && + verifiedElement.GetBoolean(), + DisplayName = (discordUser.TryGetProperty("global_name", out var globalNameElement) + ? globalNameElement.GetString() + : null) ?? "", + PreferredUsername = discordUser.GetProperty("username").GetString() ?? "", + ProfilePictureUrl = !string.IsNullOrEmpty(avatar) + ? $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png" + : "", + Provider = ProviderName + }; + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Auth/OpenId/OidcController.cs b/DysonNetwork.Sphere/Auth/OpenId/OidcController.cs index 696ff76..a868ef5 100644 --- a/DysonNetwork.Sphere/Auth/OpenId/OidcController.cs +++ b/DysonNetwork.Sphere/Auth/OpenId/OidcController.cs @@ -120,6 +120,7 @@ public class OidcController( "microsoft" => serviceProvider.GetRequiredService(), "discord" => serviceProvider.GetRequiredService(), "github" => serviceProvider.GetRequiredService(), + "afdian" => serviceProvider.GetRequiredService(), _ => throw new ArgumentException($"Unsupported provider: {provider}") }; } diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs index 7041f15..a07d0c3 100644 --- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs @@ -53,11 +53,13 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddControllers().AddJsonOptions(options => {