From a1a91007818964040684828df67d9a9f75d1c443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Sun, 15 Feb 2026 19:13:53 +0800 Subject: [PATCH 01/17] =?UTF-8?q?=E6=88=91=E4=BB=AC=E5=AD=A4=E8=BA=AB?= =?UTF-8?q?=E9=97=AF=E5=85=A5=E8=BF=99=E4=B8=96=E7=95=8C=E9=87=8C=20?= =?UTF-8?q?=E6=89=BE=E5=AF=BB=E5=90=8D=E4=B8=BA=E5=AE=9D=E8=97=8F=E7=9A=84?= =?UTF-8?q?=E9=80=9A=E5=85=B3=E5=A5=96=E5=8A=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [SKIP CI] --- IdentityModel/OAuth/AuthorizeResult.cs | 24 +++++++ IdentityModel/OAuth/Client.cs | 87 ++++++++++++++++++++++++++ IdentityModel/OAuth/ClientOptions.cs | 12 ++++ IdentityModel/OAuth/DeviceCodeData.cs | 23 +++++++ IdentityModel/OAuth/EndpointMeta.cs | 9 +++ 5 files changed, 155 insertions(+) create mode 100644 IdentityModel/OAuth/AuthorizeResult.cs create mode 100644 IdentityModel/OAuth/Client.cs create mode 100644 IdentityModel/OAuth/ClientOptions.cs create mode 100644 IdentityModel/OAuth/DeviceCodeData.cs create mode 100644 IdentityModel/OAuth/EndpointMeta.cs diff --git a/IdentityModel/OAuth/AuthorizeResult.cs b/IdentityModel/OAuth/AuthorizeResult.cs new file mode 100644 index 000000000..e6934c5dd --- /dev/null +++ b/IdentityModel/OAuth/AuthorizeResult.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +using PCL.Core.Utils.Exts; + +namespace PCL.Core.IdentityModel.OAuth; + +public record AuthorizeResult +{ + public bool IsSecuess => Error.IsNullOrEmpty(); + /// + /// 错误类型 (e.g. invalid_request) + /// + [JsonPropertyName("error")] public string? Error; + /// + /// 描述此错误的文本 + /// + [JsonPropertyName("error_descripton")] public string? ErrorDescription; + + // 不用 SecureString,因为这东西依赖 DPAPI,不是最佳实践 + + [JsonPropertyName("access_token")] public string? AccessToken; + [JsonPropertyName("refresh_token")] public string? RefreshToken; + [JsonPropertyName("token_type")] public string? TokenType; + [JsonPropertyName("expires_in")] public int? ExpiresIn; +} \ No newline at end of file diff --git a/IdentityModel/OAuth/Client.cs b/IdentityModel/OAuth/Client.cs new file mode 100644 index 000000000..2e70c7fc7 --- /dev/null +++ b/IdentityModel/OAuth/Client.cs @@ -0,0 +1,87 @@ +using System; +using System.Text; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; + + +namespace PCL.Core.IdentityModel.OAuth; + +/// +/// OAuth 客户端实现 +/// +/// 获取 HttpClient 的方法 +/// OAuth 参数 +public sealed class SimpleOAuthClient(Func getClient, OAuthClientOptions options) +{ + /// + /// 获取授权 Url + /// + /// 访问权限列表 + /// 重定向 Url + /// + public string GetAuthorizeUrl(string[] scopes,string redirectUri) + { + ArgumentException.ThrowIfNullOrWhiteSpace(options.Meta.AuthorizeEndpoint); + var sb = new StringBuilder(); + sb.Append(options.Meta.AuthorizeEndpoint); + sb.Append($"?response_type=code&scope={string.Join(" ", scopes)}"); + sb.Append($"&redirect_uri={redirectUri}&client_id={options.Meta.ClientId}"); + return Uri.EscapeDataString(sb.ToString()); + } + + /// + /// 使用授权代码获取 AccessToken + /// + /// 授权代码 + /// 附加属性,不应该包含必须参数和预定义字段 (e.g. client_id、grant_type) + /// + public async Task AuthorizeWithCodeAsync( + string code,Dictionary? extData = null + ) + { + extData ??= new Dictionary(); + extData["client_id"] = options.Meta.ClientId; + extData["grant_type"] = "authorization_code"; + extData["code"] = code; + var client = getClient.Invoke(); + using var content = new FormUrlEncodedContent(extData); + using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request); + var result = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(result); + } + + public async Task GetCodePairAsync + (string[] scopes, Dictionary? extData = null) + { + var client = getClient.Invoke(); + extData ??= new Dictionary(); + extData["scope"] = string.Join(" ", scopes); + extData["client_id"] = options.Meta.ClientId; + using var request = new HttpRequestMessage(HttpMethod.Post, options.Meta.DeviceEndpoint); + var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request); + var result = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(result); + } + + public async Task AuthorizeWithDeviceCode(DeviceCodeData data,Dictionary extData) + { + var client = getClient.Invoke(); + } + + public async Task AuthorizeWithSilentAsync(AuthorizeResult data) + { + + } +} \ No newline at end of file diff --git a/IdentityModel/OAuth/ClientOptions.cs b/IdentityModel/OAuth/ClientOptions.cs new file mode 100644 index 000000000..19734955d --- /dev/null +++ b/IdentityModel/OAuth/ClientOptions.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace PCL.Core.IdentityModel.OAuth; + +public record OAuthClientOptions +{ + public Dictionary? Headers { + get; + set; + } + public required EndpointMeta Meta { get; set; } +} \ No newline at end of file diff --git a/IdentityModel/OAuth/DeviceCodeData.cs b/IdentityModel/OAuth/DeviceCodeData.cs new file mode 100644 index 000000000..e449fd8c8 --- /dev/null +++ b/IdentityModel/OAuth/DeviceCodeData.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using PCL.Core.Utils.Exts; + +namespace PCL.Core.IdentityModel.OAuth; + +public record DeviceCodeData +{ + public bool IsError => !Error.IsNullOrEmpty(); + [JsonPropertyName("error")] public string? Error; + + [JsonPropertyName("error_description")] + public string? ErrorDescription; + + [JsonPropertyName("user_code")] public string? UserCode; + [JsonPropertyName("device_code")] public string? DeviceCode; + [JsonPropertyName("verification_uri")] public string? VerificationUri; + + [JsonPropertyName("verification_uri_complete")] + public string? VerificationUriComplete; + + [JsonPropertyName("interval")] public int? Interval; + [JsonPropertyName("expired_in")] public int? ExpiredIn; +} \ No newline at end of file diff --git a/IdentityModel/OAuth/EndpointMeta.cs b/IdentityModel/OAuth/EndpointMeta.cs new file mode 100644 index 000000000..e09346d1d --- /dev/null +++ b/IdentityModel/OAuth/EndpointMeta.cs @@ -0,0 +1,9 @@ +namespace PCL.Core.IdentityModel.OAuth; + +public record EndpointMeta +{ + public string? DeviceEndpoint { get; set; } + public required string AuthorizeEndpoint { get; set; } + public required string ClientId { get; set; } + public required string TokenEndpoint { get; set; } +} \ No newline at end of file From f10b8225e57c6f006676b8b45bc83460d1843720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Sun, 15 Feb 2026 20:39:06 +0800 Subject: [PATCH 02/17] =?UTF-8?q?=E8=B6=8A=E8=BF=87=E6=B7=B1=E6=B8=8A?= =?UTF-8?q?=E9=87=8C=E8=BF=B7=E4=BA=BA=E7=9A=84=E9=87=91=E5=B8=81=20?= =?UTF-8?q?=E6=9C=80=E5=90=8E=E5=8F=91=E7=8E=B0=20=E7=8F=8D=E8=B4=B5?= =?UTF-8?q?=E6=98=AF=E6=88=91=E5=92=8C=E4=BD=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IdentityModel/OAuth/Client.cs | 88 ++++++++++++++++++++++++++++------- 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/IdentityModel/OAuth/Client.cs b/IdentityModel/OAuth/Client.cs index 2e70c7fc7..ab0c61c5b 100644 --- a/IdentityModel/OAuth/Client.cs +++ b/IdentityModel/OAuth/Client.cs @@ -4,14 +4,15 @@ using System.Text.Json; using System.Threading.Tasks; using System.Collections.Generic; +using System.Threading; namespace PCL.Core.IdentityModel.OAuth; /// -/// OAuth 客户端实现 +/// OAuth 客户端实现,配合 Polly 食用效果更佳 /// -/// 获取 HttpClient 的方法 +/// 获取 HttpClient 的方法,实现方需自行管理 HttpClient 生命周期 /// OAuth 参数 public sealed class SimpleOAuthClient(Func getClient, OAuthClientOptions options) { @@ -26,9 +27,10 @@ public string GetAuthorizeUrl(string[] scopes,string redirectUri) ArgumentException.ThrowIfNullOrWhiteSpace(options.Meta.AuthorizeEndpoint); var sb = new StringBuilder(); sb.Append(options.Meta.AuthorizeEndpoint); - sb.Append($"?response_type=code&scope={string.Join(" ", scopes)}"); - sb.Append($"&redirect_uri={redirectUri}&client_id={options.Meta.ClientId}"); - return Uri.EscapeDataString(sb.ToString()); + sb.Append($"?response_type=code&scope={Uri.EscapeDataString(string.Join(" ", scopes))}"); + sb.Append($"&redirect_uri={Uri.EscapeDataString(redirectUri)}"); + sb.Append($"&client_id={options.Meta.ClientId}"); + return sb.ToString(); } /// @@ -36,9 +38,10 @@ public string GetAuthorizeUrl(string[] scopes,string redirectUri) /// /// 授权代码 /// 附加属性,不应该包含必须参数和预定义字段 (e.g. client_id、grant_type) + /// /// public async Task AuthorizeWithCodeAsync( - string code,Dictionary? extData = null + string code,CancellationToken token,Dictionary? extData = null ) { extData ??= new Dictionary(); @@ -52,13 +55,19 @@ public string GetAuthorizeUrl(string[] scopes,string redirectUri) if(options.Headers is not null) foreach (var kvp in options.Headers) _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); - using var response = await client.SendAsync(request); - var result = await response.Content.ReadAsStringAsync(); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); return JsonSerializer.Deserialize(result); } - + /// + /// 获取设备代码对 + /// + /// + /// + /// + /// public async Task GetCodePairAsync - (string[] scopes, Dictionary? extData = null) + (string[] scopes,CancellationToken token, Dictionary? extData = null) { var client = getClient.Invoke(); extData ??= new Dictionary(); @@ -70,18 +79,63 @@ public async Task GetCodePairAsync if(options.Headers is not null) foreach (var kvp in options.Headers) _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); - using var response = await client.SendAsync(request); - var result = await response.Content.ReadAsStringAsync(); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); return JsonSerializer.Deserialize(result); } - - public async Task AuthorizeWithDeviceCode(DeviceCodeData data,Dictionary extData) + /// + /// 验证用户授权状态
+ /// 注:此方法不会检查是否过去了 Interval 秒,请自行处理 + ///
+ /// + /// + /// + /// + /// + public async Task AuthorizeWithDeviceCode + (DeviceCodeData data,CancellationToken token,Dictionary? extData = null) { + if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); var client = getClient.Invoke(); + extData ??= new Dictionary(); + extData["client_id"] = options.Meta.ClientId; + extData["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code"; + extData["device_code"] = data.DeviceCode!; + using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + using var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); } - - public async Task AuthorizeWithSilentAsync(AuthorizeResult data) + /// + /// 刷新登录 + /// + /// + /// + /// + /// + /// + public async Task AuthorizeWithSilentAsync + (AuthorizeResult data,CancellationToken token,Dictionary? extData = null) { - + var client = getClient.Invoke(); + if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); + extData ??= new Dictionary(); + extData["refresh_token"] = data.RefreshToken!; + extData["grant_type"] = "refresh_token"; + extData["client_id"] = options.Meta.ClientId; + using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + using var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); } } \ No newline at end of file From aa9fd4e8a802b8f9b618832668e5dbc35b1de1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Tue, 17 Feb 2026 00:52:08 +0800 Subject: [PATCH 03/17] =?UTF-8?q?=E7=BB=88=E4=BA=8E=E4=BD=A0=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E8=AF=B4=E8=B5=B7=E9=82=A3=E4=BA=9B=E5=BF=83=E7=A2=8E?= =?UTF-8?q?=E7=9A=84=E8=BF=87=E5=8E=BB=20=E4=B8=8D=E8=BF=87=E6=98=AF=20?= =?UTF-8?q?=E4=B8=80=E5=9C=BA=20=E7=8B=82=E9=A3=8E=E6=9A=B4=E9=9B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IdentityModel/OAuth/IOAuthClient.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 IdentityModel/OAuth/IOAuthClient.cs diff --git a/IdentityModel/OAuth/IOAuthClient.cs b/IdentityModel/OAuth/IOAuthClient.cs new file mode 100644 index 000000000..4eec2f781 --- /dev/null +++ b/IdentityModel/OAuth/IOAuthClient.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + +namespace PCL.Core.IdentityModel.OAuth; + +public interface IOAuthClient +{ + public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state); + public Task AuthorizeWithCodeAsync(string code,CancellationToken token,Dictionary? extData = null); + public Task GetCodePairAsync(string[] scopes,CancellationToken token, Dictionary? extData = null); + public Task AuthorizeWithDeviceAsync(DeviceCodeData data,CancellationToken token,Dictionary? extData = null); + public Task AuthorizeWithSilentAsync(AuthorizeResult data,CancellationToken token,Dictionary? extData = null); +} \ No newline at end of file From 59c5ffaa3d64ba0cfd67720f4eb72f9bed3de7b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Tue, 17 Feb 2026 00:55:26 +0800 Subject: [PATCH 04/17] =?UTF-8?q?=E4=BC=A0=E8=AF=B4=E4=B8=AD=E5=81=8F?= =?UTF-8?q?=E7=88=B1=E5=B0=91=E5=B9=B4=E5=8B=87=E6=95=A2=E4=B8=BE=E8=B5=B7?= =?UTF-8?q?=E7=9A=84=E6=89=8B=E8=87=82=20=E6=88=91=E7=88=B1=E4=BD=A0=20?= =?UTF-8?q?=E7=AC=A8=E6=8B=99=20=E7=9A=84=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IdentityModel/OAuth/ClientOption.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 IdentityModel/OAuth/ClientOption.cs diff --git a/IdentityModel/OAuth/ClientOption.cs b/IdentityModel/OAuth/ClientOption.cs new file mode 100644 index 000000000..5b8b0aae8 --- /dev/null +++ b/IdentityModel/OAuth/ClientOption.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace PCL.Core.IdentityModel.OAuth; + +public record OAuthClientOptions +{ + /// + /// + /// + public Dictionary? Headers { get; set; } + public required EndpointMeta Meta { get; set; } + public required Func GetClient { get; set; } + + public required string RedirectUri { get; set; } + public required string ClientId { get; set; } + +} \ No newline at end of file From 81ebe6a3e636ed3ac88bc61bb135ebdcb38d8300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Tue, 17 Feb 2026 00:56:38 +0800 Subject: [PATCH 05/17] =?UTF-8?q?=E4=BB=8E=E8=B9=92=E8=B7=9A=20=E5=88=B0?= =?UTF-8?q?=E5=A5=94=E8=A2=AD=20=E4=BB=8E=E6=B2=99=E6=BC=A0=20=E5=88=B0?= =?UTF-8?q?=E8=8D=86=E6=A3=98=20=E4=BD=A0=E5=B7=B2=E8=B5=A4=E8=84=9A?= =?UTF-8?q?=E7=A9=BF=E8=BF=87=20=E8=BF=99=E7=89=87=E9=99=86=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IdentityModel/OAuth/ClientOption.cs | 19 ------------------- IdentityModel/OAuth/ClientOptions.cs | 12 ------------ 2 files changed, 31 deletions(-) delete mode 100644 IdentityModel/OAuth/ClientOption.cs delete mode 100644 IdentityModel/OAuth/ClientOptions.cs diff --git a/IdentityModel/OAuth/ClientOption.cs b/IdentityModel/OAuth/ClientOption.cs deleted file mode 100644 index 5b8b0aae8..000000000 --- a/IdentityModel/OAuth/ClientOption.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; - -namespace PCL.Core.IdentityModel.OAuth; - -public record OAuthClientOptions -{ - /// - /// - /// - public Dictionary? Headers { get; set; } - public required EndpointMeta Meta { get; set; } - public required Func GetClient { get; set; } - - public required string RedirectUri { get; set; } - public required string ClientId { get; set; } - -} \ No newline at end of file diff --git a/IdentityModel/OAuth/ClientOptions.cs b/IdentityModel/OAuth/ClientOptions.cs deleted file mode 100644 index 19734955d..000000000 --- a/IdentityModel/OAuth/ClientOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; - -namespace PCL.Core.IdentityModel.OAuth; - -public record OAuthClientOptions -{ - public Dictionary? Headers { - get; - set; - } - public required EndpointMeta Meta { get; set; } -} \ No newline at end of file From 44a5847752171ca044ed13eb5aa535c2d80f67dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Tue, 17 Feb 2026 00:57:39 +0800 Subject: [PATCH 06/17] =?UTF-8?q?=E4=B9=A0=E6=83=AF=E4=BA=86=20=E7=A9=BA?= =?UTF-8?q?=E6=AC=A2=E5=96=9C=20=E5=AD=A6=E4=BC=9A=E4=BA=86=E4=B8=8D?= =?UTF-8?q?=E5=93=AD=E6=B3=A3=20=E6=AF=8F=E9=A2=97=E7=8F=8D=E7=8F=A0?= =?UTF-8?q?=E9=83=BD=E6=9B=BE=E6=98=AF=20=E7=97=9B=E8=BF=87=E7=9A=84?= =?UTF-8?q?=E6=B2=99=E7=B2=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IdentityModel/OAuth/ClientOptions.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 IdentityModel/OAuth/ClientOptions.cs diff --git a/IdentityModel/OAuth/ClientOptions.cs b/IdentityModel/OAuth/ClientOptions.cs new file mode 100644 index 000000000..5b8b0aae8 --- /dev/null +++ b/IdentityModel/OAuth/ClientOptions.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace PCL.Core.IdentityModel.OAuth; + +public record OAuthClientOptions +{ + /// + /// + /// + public Dictionary? Headers { get; set; } + public required EndpointMeta Meta { get; set; } + public required Func GetClient { get; set; } + + public required string RedirectUri { get; set; } + public required string ClientId { get; set; } + +} \ No newline at end of file From a4b9a318b58e6cca0daa0666600ad8e05d060e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Tue, 17 Feb 2026 01:00:48 +0800 Subject: [PATCH 07/17] =?UTF-8?q?=E6=88=91=E5=9C=A8=E7=AD=89=E4=BD=A0=20?= =?UTF-8?q?=E6=89=BE=E5=88=B0=E4=BD=A0=20=E4=B8=80=E7=9B=B4=E5=88=B0?= =?UTF-8?q?=E5=A4=AA=E9=98=B3=E5=8D=87=E8=B5=B7=20=E5=A4=9A=E5=B0=91?= =?UTF-8?q?=E6=AC=A1=E5=9D=A0=E4=B8=8B=E8=B0=B7=E5=BA=95=20=E4=B9=9F?= =?UTF-8?q?=E8=83=BD=E6=8A=B1=E4=BD=8F=E8=87=AA=E5=B7=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IdentityModel/OAuth/Client.cs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/IdentityModel/OAuth/Client.cs b/IdentityModel/OAuth/Client.cs index ab0c61c5b..18fd6c2bb 100644 --- a/IdentityModel/OAuth/Client.cs +++ b/IdentityModel/OAuth/Client.cs @@ -6,30 +6,29 @@ using System.Collections.Generic; using System.Threading; - namespace PCL.Core.IdentityModel.OAuth; /// /// OAuth 客户端实现,配合 Polly 食用效果更佳 /// -/// 获取 HttpClient 的方法,实现方需自行管理 HttpClient 生命周期 /// OAuth 参数 -public sealed class SimpleOAuthClient(Func getClient, OAuthClientOptions options) +public sealed class SimepleOAuthClient(OAuthClientOptions options):IOAuthClient { /// /// 获取授权 Url /// /// 访问权限列表 /// 重定向 Url + /// /// - public string GetAuthorizeUrl(string[] scopes,string redirectUri) + public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state) { ArgumentException.ThrowIfNullOrWhiteSpace(options.Meta.AuthorizeEndpoint); var sb = new StringBuilder(); sb.Append(options.Meta.AuthorizeEndpoint); sb.Append($"?response_type=code&scope={Uri.EscapeDataString(string.Join(" ", scopes))}"); sb.Append($"&redirect_uri={Uri.EscapeDataString(redirectUri)}"); - sb.Append($"&client_id={options.Meta.ClientId}"); + sb.Append($"&client_id={options.ClientId}&state={state}"); return sb.ToString(); } @@ -45,10 +44,10 @@ public string GetAuthorizeUrl(string[] scopes,string redirectUri) ) { extData ??= new Dictionary(); - extData["client_id"] = options.Meta.ClientId; + extData["client_id"] = options.ClientId; extData["grant_type"] = "authorization_code"; extData["code"] = code; - var client = getClient.Invoke(); + var client = options.GetClient.Invoke(); using var content = new FormUrlEncodedContent(extData); using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); request.Content = content; @@ -69,10 +68,10 @@ public string GetAuthorizeUrl(string[] scopes,string redirectUri) public async Task GetCodePairAsync (string[] scopes,CancellationToken token, Dictionary? extData = null) { - var client = getClient.Invoke(); + var client = options.GetClient.Invoke(); extData ??= new Dictionary(); extData["scope"] = string.Join(" ", scopes); - extData["client_id"] = options.Meta.ClientId; + extData["client_id"] = options.ClientId; using var request = new HttpRequestMessage(HttpMethod.Post, options.Meta.DeviceEndpoint); var content = new FormUrlEncodedContent(extData); request.Content = content; @@ -92,13 +91,13 @@ public async Task GetCodePairAsync /// /// /// - public async Task AuthorizeWithDeviceCode + public async Task AuthorizeWithDeviceAsync (DeviceCodeData data,CancellationToken token,Dictionary? extData = null) { if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); - var client = getClient.Invoke(); + var client = options.GetClient.Invoke(); extData ??= new Dictionary(); - extData["client_id"] = options.Meta.ClientId; + extData["client_id"] = options.ClientId; extData["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code"; extData["device_code"] = data.DeviceCode!; using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); @@ -122,12 +121,12 @@ public async Task AuthorizeWithDeviceCode public async Task AuthorizeWithSilentAsync (AuthorizeResult data,CancellationToken token,Dictionary? extData = null) { - var client = getClient.Invoke(); + var client = options.GetClient.Invoke(); if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); extData ??= new Dictionary(); extData["refresh_token"] = data.RefreshToken!; extData["grant_type"] = "refresh_token"; - extData["client_id"] = options.Meta.ClientId; + extData["client_id"] = options.ClientId; using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); using var content = new FormUrlEncodedContent(extData); request.Content = content; From aa491832e3a8c106a8282af5d265f93069dcd5ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Tue, 17 Feb 2026 01:24:46 +0800 Subject: [PATCH 08/17] =?UTF-8?q?=E5=B1=B1=E4=B8=8A=E7=9A=84=E9=A3=8E=20?= =?UTF-8?q?=E5=9C=B0=E5=BF=83=E7=9A=84=E5=8A=9B=20=E7=94=9F=E5=91=BD?= =?UTF-8?q?=E5=90=91=E4=B8=8A=E9=95=BF=E6=88=90=E4=BA=86=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=20=E9=82=A3=E6=97=B6=E4=BD=A0=E4=BC=9A=E7=9C=8B=E5=88=B0?= =?UTF-8?q?=E6=98=A5=E9=87=8E=E6=BB=A1=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IdentityModel}/OAuth/AuthorizeResult.cs | 46 +-- .../IdentityModel}/OAuth/Client.cs | 278 +++++++++--------- .../IdentityModel}/OAuth/ClientOptions.cs | 36 +-- .../IdentityModel}/OAuth/DeviceCodeData.cs | 44 +-- .../IdentityModel}/OAuth/EndpointMeta.cs | 16 +- .../IdentityModel}/OAuth/IOAuthClient.cs | 26 +- 6 files changed, 223 insertions(+), 223 deletions(-) rename {IdentityModel => PCL.Core/IdentityModel}/OAuth/AuthorizeResult.cs (97%) rename {IdentityModel => PCL.Core/IdentityModel}/OAuth/Client.cs (97%) rename {IdentityModel => PCL.Core/IdentityModel}/OAuth/ClientOptions.cs (96%) rename {IdentityModel => PCL.Core/IdentityModel}/OAuth/DeviceCodeData.cs (97%) rename {IdentityModel => PCL.Core/IdentityModel}/OAuth/EndpointMeta.cs (97%) rename {IdentityModel => PCL.Core/IdentityModel}/OAuth/IOAuthClient.cs (98%) diff --git a/IdentityModel/OAuth/AuthorizeResult.cs b/PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs similarity index 97% rename from IdentityModel/OAuth/AuthorizeResult.cs rename to PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs index e6934c5dd..81d3a172c 100644 --- a/IdentityModel/OAuth/AuthorizeResult.cs +++ b/PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs @@ -1,24 +1,24 @@ -using System.Text.Json.Serialization; -using PCL.Core.Utils.Exts; - -namespace PCL.Core.IdentityModel.OAuth; - -public record AuthorizeResult -{ - public bool IsSecuess => Error.IsNullOrEmpty(); - /// - /// 错误类型 (e.g. invalid_request) - /// - [JsonPropertyName("error")] public string? Error; - /// - /// 描述此错误的文本 - /// - [JsonPropertyName("error_descripton")] public string? ErrorDescription; - - // 不用 SecureString,因为这东西依赖 DPAPI,不是最佳实践 - - [JsonPropertyName("access_token")] public string? AccessToken; - [JsonPropertyName("refresh_token")] public string? RefreshToken; - [JsonPropertyName("token_type")] public string? TokenType; - [JsonPropertyName("expires_in")] public int? ExpiresIn; +using System.Text.Json.Serialization; +using PCL.Core.Utils.Exts; + +namespace PCL.Core.IdentityModel.OAuth; + +public record AuthorizeResult +{ + public bool IsSecuess => Error.IsNullOrEmpty(); + /// + /// 错误类型 (e.g. invalid_request) + /// + [JsonPropertyName("error")] public string? Error; + /// + /// 描述此错误的文本 + /// + [JsonPropertyName("error_descripton")] public string? ErrorDescription; + + // 不用 SecureString,因为这东西依赖 DPAPI,不是最佳实践 + + [JsonPropertyName("access_token")] public string? AccessToken; + [JsonPropertyName("refresh_token")] public string? RefreshToken; + [JsonPropertyName("token_type")] public string? TokenType; + [JsonPropertyName("expires_in")] public int? ExpiresIn; } \ No newline at end of file diff --git a/IdentityModel/OAuth/Client.cs b/PCL.Core/IdentityModel/OAuth/Client.cs similarity index 97% rename from IdentityModel/OAuth/Client.cs rename to PCL.Core/IdentityModel/OAuth/Client.cs index 18fd6c2bb..ecae4f636 100644 --- a/IdentityModel/OAuth/Client.cs +++ b/PCL.Core/IdentityModel/OAuth/Client.cs @@ -1,140 +1,140 @@ -using System; -using System.Text; -using System.Net.Http; -using System.Text.Json; -using System.Threading.Tasks; -using System.Collections.Generic; -using System.Threading; - -namespace PCL.Core.IdentityModel.OAuth; - -/// -/// OAuth 客户端实现,配合 Polly 食用效果更佳 -/// -/// OAuth 参数 -public sealed class SimepleOAuthClient(OAuthClientOptions options):IOAuthClient -{ - /// - /// 获取授权 Url - /// - /// 访问权限列表 - /// 重定向 Url - /// - /// - public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state) - { - ArgumentException.ThrowIfNullOrWhiteSpace(options.Meta.AuthorizeEndpoint); - var sb = new StringBuilder(); - sb.Append(options.Meta.AuthorizeEndpoint); - sb.Append($"?response_type=code&scope={Uri.EscapeDataString(string.Join(" ", scopes))}"); - sb.Append($"&redirect_uri={Uri.EscapeDataString(redirectUri)}"); - sb.Append($"&client_id={options.ClientId}&state={state}"); - return sb.ToString(); - } - - /// - /// 使用授权代码获取 AccessToken - /// - /// 授权代码 - /// 附加属性,不应该包含必须参数和预定义字段 (e.g. client_id、grant_type) - /// - /// - public async Task AuthorizeWithCodeAsync( - string code,CancellationToken token,Dictionary? extData = null - ) - { - extData ??= new Dictionary(); - extData["client_id"] = options.ClientId; - extData["grant_type"] = "authorization_code"; - extData["code"] = code; - var client = options.GetClient.Invoke(); - using var content = new FormUrlEncodedContent(extData); - using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); - request.Content = content; - if(options.Headers is not null) - foreach (var kvp in options.Headers) - _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); - using var response = await client.SendAsync(request,token); - var result = await response.Content.ReadAsStringAsync(token); - return JsonSerializer.Deserialize(result); - } - /// - /// 获取设备代码对 - /// - /// - /// - /// - /// - public async Task GetCodePairAsync - (string[] scopes,CancellationToken token, Dictionary? extData = null) - { - var client = options.GetClient.Invoke(); - extData ??= new Dictionary(); - extData["scope"] = string.Join(" ", scopes); - extData["client_id"] = options.ClientId; - using var request = new HttpRequestMessage(HttpMethod.Post, options.Meta.DeviceEndpoint); - var content = new FormUrlEncodedContent(extData); - request.Content = content; - if(options.Headers is not null) - foreach (var kvp in options.Headers) - _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); - using var response = await client.SendAsync(request,token); - var result = await response.Content.ReadAsStringAsync(token); - return JsonSerializer.Deserialize(result); - } - /// - /// 验证用户授权状态
- /// 注:此方法不会检查是否过去了 Interval 秒,请自行处理 - ///
- /// - /// - /// - /// - /// - public async Task AuthorizeWithDeviceAsync - (DeviceCodeData data,CancellationToken token,Dictionary? extData = null) - { - if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); - var client = options.GetClient.Invoke(); - extData ??= new Dictionary(); - extData["client_id"] = options.ClientId; - extData["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code"; - extData["device_code"] = data.DeviceCode!; - using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); - using var content = new FormUrlEncodedContent(extData); - request.Content = content; - if(options.Headers is not null) - foreach (var kvp in options.Headers) - _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); - using var response = await client.SendAsync(request,token); - var result = await response.Content.ReadAsStringAsync(token); - return JsonSerializer.Deserialize(result); - } - /// - /// 刷新登录 - /// - /// - /// - /// - /// - /// - public async Task AuthorizeWithSilentAsync - (AuthorizeResult data,CancellationToken token,Dictionary? extData = null) - { - var client = options.GetClient.Invoke(); - if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); - extData ??= new Dictionary(); - extData["refresh_token"] = data.RefreshToken!; - extData["grant_type"] = "refresh_token"; - extData["client_id"] = options.ClientId; - using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); - using var content = new FormUrlEncodedContent(extData); - request.Content = content; - if(options.Headers is not null) - foreach (var kvp in options.Headers) - _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); - using var response = await client.SendAsync(request,token); - var result = await response.Content.ReadAsStringAsync(token); - return JsonSerializer.Deserialize(result); - } +using System; +using System.Text; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading; + +namespace PCL.Core.IdentityModel.OAuth; + +/// +/// OAuth 客户端实现,配合 Polly 食用效果更佳 +/// +/// OAuth 参数 +public sealed class SimepleOAuthClient(OAuthClientOptions options):IOAuthClient +{ + /// + /// 获取授权 Url + /// + /// 访问权限列表 + /// 重定向 Url + /// + /// + public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state) + { + ArgumentException.ThrowIfNullOrWhiteSpace(options.Meta.AuthorizeEndpoint); + var sb = new StringBuilder(); + sb.Append(options.Meta.AuthorizeEndpoint); + sb.Append($"?response_type=code&scope={Uri.EscapeDataString(string.Join(" ", scopes))}"); + sb.Append($"&redirect_uri={Uri.EscapeDataString(redirectUri)}"); + sb.Append($"&client_id={options.ClientId}&state={state}"); + return sb.ToString(); + } + + /// + /// 使用授权代码获取 AccessToken + /// + /// 授权代码 + /// 附加属性,不应该包含必须参数和预定义字段 (e.g. client_id、grant_type) + /// + /// + public async Task AuthorizeWithCodeAsync( + string code,CancellationToken token,Dictionary? extData = null + ) + { + extData ??= new Dictionary(); + extData["client_id"] = options.ClientId; + extData["grant_type"] = "authorization_code"; + extData["code"] = code; + var client = options.GetClient.Invoke(); + using var content = new FormUrlEncodedContent(extData); + using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } + /// + /// 获取设备代码对 + /// + /// + /// + /// + /// + public async Task GetCodePairAsync + (string[] scopes,CancellationToken token, Dictionary? extData = null) + { + var client = options.GetClient.Invoke(); + extData ??= new Dictionary(); + extData["scope"] = string.Join(" ", scopes); + extData["client_id"] = options.ClientId; + using var request = new HttpRequestMessage(HttpMethod.Post, options.Meta.DeviceEndpoint); + var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } + /// + /// 验证用户授权状态
+ /// 注:此方法不会检查是否过去了 Interval 秒,请自行处理 + ///
+ /// + /// + /// + /// + /// + public async Task AuthorizeWithDeviceAsync + (DeviceCodeData data,CancellationToken token,Dictionary? extData = null) + { + if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); + var client = options.GetClient.Invoke(); + extData ??= new Dictionary(); + extData["client_id"] = options.ClientId; + extData["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code"; + extData["device_code"] = data.DeviceCode!; + using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + using var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } + /// + /// 刷新登录 + /// + /// + /// + /// + /// + /// + public async Task AuthorizeWithSilentAsync + (AuthorizeResult data,CancellationToken token,Dictionary? extData = null) + { + var client = options.GetClient.Invoke(); + if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); + extData ??= new Dictionary(); + extData["refresh_token"] = data.RefreshToken!; + extData["grant_type"] = "refresh_token"; + extData["client_id"] = options.ClientId; + using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + using var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } } \ No newline at end of file diff --git a/IdentityModel/OAuth/ClientOptions.cs b/PCL.Core/IdentityModel/OAuth/ClientOptions.cs similarity index 96% rename from IdentityModel/OAuth/ClientOptions.cs rename to PCL.Core/IdentityModel/OAuth/ClientOptions.cs index 5b8b0aae8..4b7476ed5 100644 --- a/IdentityModel/OAuth/ClientOptions.cs +++ b/PCL.Core/IdentityModel/OAuth/ClientOptions.cs @@ -1,19 +1,19 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; - -namespace PCL.Core.IdentityModel.OAuth; - -public record OAuthClientOptions -{ - /// - /// - /// - public Dictionary? Headers { get; set; } - public required EndpointMeta Meta { get; set; } - public required Func GetClient { get; set; } - - public required string RedirectUri { get; set; } - public required string ClientId { get; set; } - +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace PCL.Core.IdentityModel.OAuth; + +public record OAuthClientOptions +{ + /// + /// + /// + public Dictionary? Headers { get; set; } + public required EndpointMeta Meta { get; set; } + public required Func GetClient { get; set; } + + public required string RedirectUri { get; set; } + public required string ClientId { get; set; } + } \ No newline at end of file diff --git a/IdentityModel/OAuth/DeviceCodeData.cs b/PCL.Core/IdentityModel/OAuth/DeviceCodeData.cs similarity index 97% rename from IdentityModel/OAuth/DeviceCodeData.cs rename to PCL.Core/IdentityModel/OAuth/DeviceCodeData.cs index e449fd8c8..dbe0ffe0c 100644 --- a/IdentityModel/OAuth/DeviceCodeData.cs +++ b/PCL.Core/IdentityModel/OAuth/DeviceCodeData.cs @@ -1,23 +1,23 @@ -using System.Text.Json.Serialization; -using PCL.Core.Utils.Exts; - -namespace PCL.Core.IdentityModel.OAuth; - -public record DeviceCodeData -{ - public bool IsError => !Error.IsNullOrEmpty(); - [JsonPropertyName("error")] public string? Error; - - [JsonPropertyName("error_description")] - public string? ErrorDescription; - - [JsonPropertyName("user_code")] public string? UserCode; - [JsonPropertyName("device_code")] public string? DeviceCode; - [JsonPropertyName("verification_uri")] public string? VerificationUri; - - [JsonPropertyName("verification_uri_complete")] - public string? VerificationUriComplete; - - [JsonPropertyName("interval")] public int? Interval; - [JsonPropertyName("expired_in")] public int? ExpiredIn; +using System.Text.Json.Serialization; +using PCL.Core.Utils.Exts; + +namespace PCL.Core.IdentityModel.OAuth; + +public record DeviceCodeData +{ + public bool IsError => !Error.IsNullOrEmpty(); + [JsonPropertyName("error")] public string? Error; + + [JsonPropertyName("error_description")] + public string? ErrorDescription; + + [JsonPropertyName("user_code")] public string? UserCode; + [JsonPropertyName("device_code")] public string? DeviceCode; + [JsonPropertyName("verification_uri")] public string? VerificationUri; + + [JsonPropertyName("verification_uri_complete")] + public string? VerificationUriComplete; + + [JsonPropertyName("interval")] public int? Interval; + [JsonPropertyName("expired_in")] public int? ExpiredIn; } \ No newline at end of file diff --git a/IdentityModel/OAuth/EndpointMeta.cs b/PCL.Core/IdentityModel/OAuth/EndpointMeta.cs similarity index 97% rename from IdentityModel/OAuth/EndpointMeta.cs rename to PCL.Core/IdentityModel/OAuth/EndpointMeta.cs index e09346d1d..f47236e0f 100644 --- a/IdentityModel/OAuth/EndpointMeta.cs +++ b/PCL.Core/IdentityModel/OAuth/EndpointMeta.cs @@ -1,9 +1,9 @@ -namespace PCL.Core.IdentityModel.OAuth; - -public record EndpointMeta -{ - public string? DeviceEndpoint { get; set; } - public required string AuthorizeEndpoint { get; set; } - public required string ClientId { get; set; } - public required string TokenEndpoint { get; set; } +namespace PCL.Core.IdentityModel.OAuth; + +public record EndpointMeta +{ + public string? DeviceEndpoint { get; set; } + public required string AuthorizeEndpoint { get; set; } + public required string ClientId { get; set; } + public required string TokenEndpoint { get; set; } } \ No newline at end of file diff --git a/IdentityModel/OAuth/IOAuthClient.cs b/PCL.Core/IdentityModel/OAuth/IOAuthClient.cs similarity index 98% rename from IdentityModel/OAuth/IOAuthClient.cs rename to PCL.Core/IdentityModel/OAuth/IOAuthClient.cs index 4eec2f781..140ab50fe 100644 --- a/IdentityModel/OAuth/IOAuthClient.cs +++ b/PCL.Core/IdentityModel/OAuth/IOAuthClient.cs @@ -1,14 +1,14 @@ -using System.Threading; -using System.Threading.Tasks; -using System.Collections.Generic; - -namespace PCL.Core.IdentityModel.OAuth; - -public interface IOAuthClient -{ - public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state); - public Task AuthorizeWithCodeAsync(string code,CancellationToken token,Dictionary? extData = null); - public Task GetCodePairAsync(string[] scopes,CancellationToken token, Dictionary? extData = null); - public Task AuthorizeWithDeviceAsync(DeviceCodeData data,CancellationToken token,Dictionary? extData = null); - public Task AuthorizeWithSilentAsync(AuthorizeResult data,CancellationToken token,Dictionary? extData = null); +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + +namespace PCL.Core.IdentityModel.OAuth; + +public interface IOAuthClient +{ + public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state); + public Task AuthorizeWithCodeAsync(string code,CancellationToken token,Dictionary? extData = null); + public Task GetCodePairAsync(string[] scopes,CancellationToken token, Dictionary? extData = null); + public Task AuthorizeWithDeviceAsync(DeviceCodeData data,CancellationToken token,Dictionary? extData = null); + public Task AuthorizeWithSilentAsync(AuthorizeResult data,CancellationToken token,Dictionary? extData = null); } \ No newline at end of file From ecf2387a7cc91054e28564e39b3bcaeaaa6a2bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Tue, 17 Feb 2026 01:27:12 +0800 Subject: [PATCH 09/17] =?UTF-8?q?=E4=BB=8E=E5=A4=B1=E7=9C=A0=20=E5=88=B0?= =?UTF-8?q?=E5=A4=B1=E6=84=8F=20=E4=BB=8E=E5=A4=B1=E8=90=BD=20=E5=88=B0?= =?UTF-8?q?=E5=A4=B1=E5=8E=BB=20=E5=A4=9A=E5=B0=91=E7=97=9B=E7=9A=84?= =?UTF-8?q?=E6=BD=AE=E6=B1=90=20=E6=9B=BE=E5=90=BB=E8=BF=87=E4=BD=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs b/PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs index 81d3a172c..3846ba45d 100644 --- a/PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs +++ b/PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs @@ -5,7 +5,7 @@ namespace PCL.Core.IdentityModel.OAuth; public record AuthorizeResult { - public bool IsSecuess => Error.IsNullOrEmpty(); + public bool IsError => !Error.IsNullOrEmpty(); /// /// 错误类型 (e.g. invalid_request) /// From 1b5b9a9d095589290a5babaebc3f9ea9dcd10b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Sat, 21 Feb 2026 13:52:18 +0800 Subject: [PATCH 10/17] =?UTF-8?q?=E6=8E=A8=E5=BC=80=E9=97=A8=20=E8=B5=B0?= =?UTF-8?q?=E5=87=BA=E5=8E=BB=20=E4=B8=96=E7=95=8C=E4=B9=9F=E5=9C=A8?= =?UTF-8?q?=E7=AD=89=E7=9D=80=E4=BD=A0=20=E4=BD=A0=E6=98=AF=E7=8F=8D?= =?UTF-8?q?=E7=8F=A0=E8=A6=81=E4=BA=B2=E6=89=8B=E6=8D=A7=E5=87=BA=E4=BD=A0?= =?UTF-8?q?=E8=87=AA=E5=B7=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IdentityModel/OAuth/AuthorizeResult.cs | 24 --- PCL.Core/IdentityModel/OAuth/Client.cs | 140 ------------------ PCL.Core/IdentityModel/OAuth/ClientOptions.cs | 19 --- .../IdentityModel/OAuth/DeviceCodeData.cs | 23 --- PCL.Core/IdentityModel/OAuth/EndpointMeta.cs | 9 -- PCL.Core/IdentityModel/OAuth/IOAuthClient.cs | 14 -- 6 files changed, 229 deletions(-) delete mode 100644 PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs delete mode 100644 PCL.Core/IdentityModel/OAuth/Client.cs delete mode 100644 PCL.Core/IdentityModel/OAuth/ClientOptions.cs delete mode 100644 PCL.Core/IdentityModel/OAuth/DeviceCodeData.cs delete mode 100644 PCL.Core/IdentityModel/OAuth/EndpointMeta.cs delete mode 100644 PCL.Core/IdentityModel/OAuth/IOAuthClient.cs diff --git a/PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs b/PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs deleted file mode 100644 index 3846ba45d..000000000 --- a/PCL.Core/IdentityModel/OAuth/AuthorizeResult.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Text.Json.Serialization; -using PCL.Core.Utils.Exts; - -namespace PCL.Core.IdentityModel.OAuth; - -public record AuthorizeResult -{ - public bool IsError => !Error.IsNullOrEmpty(); - /// - /// 错误类型 (e.g. invalid_request) - /// - [JsonPropertyName("error")] public string? Error; - /// - /// 描述此错误的文本 - /// - [JsonPropertyName("error_descripton")] public string? ErrorDescription; - - // 不用 SecureString,因为这东西依赖 DPAPI,不是最佳实践 - - [JsonPropertyName("access_token")] public string? AccessToken; - [JsonPropertyName("refresh_token")] public string? RefreshToken; - [JsonPropertyName("token_type")] public string? TokenType; - [JsonPropertyName("expires_in")] public int? ExpiresIn; -} \ No newline at end of file diff --git a/PCL.Core/IdentityModel/OAuth/Client.cs b/PCL.Core/IdentityModel/OAuth/Client.cs deleted file mode 100644 index ecae4f636..000000000 --- a/PCL.Core/IdentityModel/OAuth/Client.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System; -using System.Text; -using System.Net.Http; -using System.Text.Json; -using System.Threading.Tasks; -using System.Collections.Generic; -using System.Threading; - -namespace PCL.Core.IdentityModel.OAuth; - -/// -/// OAuth 客户端实现,配合 Polly 食用效果更佳 -/// -/// OAuth 参数 -public sealed class SimepleOAuthClient(OAuthClientOptions options):IOAuthClient -{ - /// - /// 获取授权 Url - /// - /// 访问权限列表 - /// 重定向 Url - /// - /// - public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state) - { - ArgumentException.ThrowIfNullOrWhiteSpace(options.Meta.AuthorizeEndpoint); - var sb = new StringBuilder(); - sb.Append(options.Meta.AuthorizeEndpoint); - sb.Append($"?response_type=code&scope={Uri.EscapeDataString(string.Join(" ", scopes))}"); - sb.Append($"&redirect_uri={Uri.EscapeDataString(redirectUri)}"); - sb.Append($"&client_id={options.ClientId}&state={state}"); - return sb.ToString(); - } - - /// - /// 使用授权代码获取 AccessToken - /// - /// 授权代码 - /// 附加属性,不应该包含必须参数和预定义字段 (e.g. client_id、grant_type) - /// - /// - public async Task AuthorizeWithCodeAsync( - string code,CancellationToken token,Dictionary? extData = null - ) - { - extData ??= new Dictionary(); - extData["client_id"] = options.ClientId; - extData["grant_type"] = "authorization_code"; - extData["code"] = code; - var client = options.GetClient.Invoke(); - using var content = new FormUrlEncodedContent(extData); - using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); - request.Content = content; - if(options.Headers is not null) - foreach (var kvp in options.Headers) - _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); - using var response = await client.SendAsync(request,token); - var result = await response.Content.ReadAsStringAsync(token); - return JsonSerializer.Deserialize(result); - } - /// - /// 获取设备代码对 - /// - /// - /// - /// - /// - public async Task GetCodePairAsync - (string[] scopes,CancellationToken token, Dictionary? extData = null) - { - var client = options.GetClient.Invoke(); - extData ??= new Dictionary(); - extData["scope"] = string.Join(" ", scopes); - extData["client_id"] = options.ClientId; - using var request = new HttpRequestMessage(HttpMethod.Post, options.Meta.DeviceEndpoint); - var content = new FormUrlEncodedContent(extData); - request.Content = content; - if(options.Headers is not null) - foreach (var kvp in options.Headers) - _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); - using var response = await client.SendAsync(request,token); - var result = await response.Content.ReadAsStringAsync(token); - return JsonSerializer.Deserialize(result); - } - /// - /// 验证用户授权状态
- /// 注:此方法不会检查是否过去了 Interval 秒,请自行处理 - ///
- /// - /// - /// - /// - /// - public async Task AuthorizeWithDeviceAsync - (DeviceCodeData data,CancellationToken token,Dictionary? extData = null) - { - if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); - var client = options.GetClient.Invoke(); - extData ??= new Dictionary(); - extData["client_id"] = options.ClientId; - extData["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code"; - extData["device_code"] = data.DeviceCode!; - using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); - using var content = new FormUrlEncodedContent(extData); - request.Content = content; - if(options.Headers is not null) - foreach (var kvp in options.Headers) - _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); - using var response = await client.SendAsync(request,token); - var result = await response.Content.ReadAsStringAsync(token); - return JsonSerializer.Deserialize(result); - } - /// - /// 刷新登录 - /// - /// - /// - /// - /// - /// - public async Task AuthorizeWithSilentAsync - (AuthorizeResult data,CancellationToken token,Dictionary? extData = null) - { - var client = options.GetClient.Invoke(); - if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); - extData ??= new Dictionary(); - extData["refresh_token"] = data.RefreshToken!; - extData["grant_type"] = "refresh_token"; - extData["client_id"] = options.ClientId; - using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); - using var content = new FormUrlEncodedContent(extData); - request.Content = content; - if(options.Headers is not null) - foreach (var kvp in options.Headers) - _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); - using var response = await client.SendAsync(request,token); - var result = await response.Content.ReadAsStringAsync(token); - return JsonSerializer.Deserialize(result); - } -} \ No newline at end of file diff --git a/PCL.Core/IdentityModel/OAuth/ClientOptions.cs b/PCL.Core/IdentityModel/OAuth/ClientOptions.cs deleted file mode 100644 index 4b7476ed5..000000000 --- a/PCL.Core/IdentityModel/OAuth/ClientOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; - -namespace PCL.Core.IdentityModel.OAuth; - -public record OAuthClientOptions -{ - /// - /// - /// - public Dictionary? Headers { get; set; } - public required EndpointMeta Meta { get; set; } - public required Func GetClient { get; set; } - - public required string RedirectUri { get; set; } - public required string ClientId { get; set; } - -} \ No newline at end of file diff --git a/PCL.Core/IdentityModel/OAuth/DeviceCodeData.cs b/PCL.Core/IdentityModel/OAuth/DeviceCodeData.cs deleted file mode 100644 index dbe0ffe0c..000000000 --- a/PCL.Core/IdentityModel/OAuth/DeviceCodeData.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Text.Json.Serialization; -using PCL.Core.Utils.Exts; - -namespace PCL.Core.IdentityModel.OAuth; - -public record DeviceCodeData -{ - public bool IsError => !Error.IsNullOrEmpty(); - [JsonPropertyName("error")] public string? Error; - - [JsonPropertyName("error_description")] - public string? ErrorDescription; - - [JsonPropertyName("user_code")] public string? UserCode; - [JsonPropertyName("device_code")] public string? DeviceCode; - [JsonPropertyName("verification_uri")] public string? VerificationUri; - - [JsonPropertyName("verification_uri_complete")] - public string? VerificationUriComplete; - - [JsonPropertyName("interval")] public int? Interval; - [JsonPropertyName("expired_in")] public int? ExpiredIn; -} \ No newline at end of file diff --git a/PCL.Core/IdentityModel/OAuth/EndpointMeta.cs b/PCL.Core/IdentityModel/OAuth/EndpointMeta.cs deleted file mode 100644 index f47236e0f..000000000 --- a/PCL.Core/IdentityModel/OAuth/EndpointMeta.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace PCL.Core.IdentityModel.OAuth; - -public record EndpointMeta -{ - public string? DeviceEndpoint { get; set; } - public required string AuthorizeEndpoint { get; set; } - public required string ClientId { get; set; } - public required string TokenEndpoint { get; set; } -} \ No newline at end of file diff --git a/PCL.Core/IdentityModel/OAuth/IOAuthClient.cs b/PCL.Core/IdentityModel/OAuth/IOAuthClient.cs deleted file mode 100644 index 140ab50fe..000000000 --- a/PCL.Core/IdentityModel/OAuth/IOAuthClient.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using System.Collections.Generic; - -namespace PCL.Core.IdentityModel.OAuth; - -public interface IOAuthClient -{ - public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state); - public Task AuthorizeWithCodeAsync(string code,CancellationToken token,Dictionary? extData = null); - public Task GetCodePairAsync(string[] scopes,CancellationToken token, Dictionary? extData = null); - public Task AuthorizeWithDeviceAsync(DeviceCodeData data,CancellationToken token,Dictionary? extData = null); - public Task AuthorizeWithSilentAsync(AuthorizeResult data,CancellationToken token,Dictionary? extData = null); -} \ No newline at end of file From 3e0ab3f1e6424f56c0094a1433c703571dd7c520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Sat, 21 Feb 2026 14:08:20 +0800 Subject: [PATCH 11/17] =?UTF-8?q?=E6=AD=A4=E5=88=BB=E7=9A=84=E4=BD=A0=20?= =?UTF-8?q?=E6=89=BE=E5=88=B0=E4=BD=A0=20=E6=88=91=E7=9C=8B=E8=A7=81?= =?UTF-8?q?=E5=A4=A7=E9=9B=BE=E6=95=A3=E5=8E=BB=20=E6=84=9F=E8=B0=A2?= =?UTF-8?q?=E4=BD=A0=E6=97=A0=E6=95=B0=E6=AC=A1=E6=8B=BC=E5=91=BD=E5=9C=B0?= =?UTF-8?q?=E6=8B=89=E4=BD=8F=E4=BA=86=E8=87=AA=E5=B7=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Minecraft/IdentityModel/OAuth/EndpointMeta.cs | 8 ++++++++ .../Minecraft/IdentityModel/OAuth/IOAuthClient.cs | 14 ++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/OAuth/IOAuthClient.cs diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs new file mode 100644 index 000000000..76d37acf1 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs @@ -0,0 +1,8 @@ +namespace PCL.Core.Minecraft.IdentityModel.OAuth; + +public record EndpointMeta +{ + public string? DeviceEndpoint { get; set; } + public required string AuthorizeEndpoint { get; set; } + public required string TokenEndpoint { get; set; } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/IOAuthClient.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/IOAuthClient.cs new file mode 100644 index 000000000..2c0a04b05 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/IOAuthClient.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + +namespace PCL.Core.Minecraft.IdentityModel.OAuth; + +public interface IOAuthClient +{ + public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state,Dictionary? extData); + public Task AuthorizeWithCodeAsync(string code,CancellationToken token,Dictionary? extData = null); + public Task GetCodePairAsync(string[] scopes,CancellationToken token, Dictionary? extData = null); + public Task AuthorizeWithDeviceAsync(DeviceCodeData data,CancellationToken token,Dictionary? extData = null); + public Task AuthorizeWithSilentAsync(AuthorizeResult data,CancellationToken token,Dictionary? extData = null); +} \ No newline at end of file From e46c27d549463f1105580f19dcdb545d8aff12b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Sat, 21 Feb 2026 14:10:00 +0800 Subject: [PATCH 12/17] =?UTF-8?q?=E8=82=A9=E4=B8=8A=E7=9A=84=E9=A3=8E=20?= =?UTF-8?q?=E5=92=8C=E6=89=8B=E5=BF=83=E7=9A=84=E5=8A=9B=20=E4=BC=9A?= =?UTF-8?q?=E5=8C=96=E4=BD=9C=E4=BD=A0=E6=8E=8C=E7=BA=B9=E7=9A=84=E7=97=95?= =?UTF-8?q?=E8=BF=B9=20=E6=AF=8F=E4=B8=AA=E6=97=A5=E5=87=BA=E6=8B=A5?= =?UTF-8?q?=E6=8A=B1=E5=B4=AD=E6=96=B0=E7=9A=84=E4=BD=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IdentityModel/OAuth/ClientOptions.cs | 18 ++++++++++ .../IdentityModel/OAuth/DeviceCodeData.cs | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/OAuth/DeviceCodeData.cs diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs new file mode 100644 index 000000000..0596dc4cb --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace PCL.Core.Minecraft.IdentityModel.OAuth; + +public record OAuthClientOptions +{ + /// + /// + /// + public Dictionary? Headers { get; set; } + public required EndpointMeta Meta { get; set; } + public required Func GetClient { get; set; } + + public required string RedirectUri { get; set; } + public required string ClientId { get; set; } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/DeviceCodeData.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/DeviceCodeData.cs new file mode 100644 index 000000000..877e6ab36 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/DeviceCodeData.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using PCL.Core.Utils.Exts; + +namespace PCL.Core.Minecraft.IdentityModel.OAuth; + +public record DeviceCodeData +{ + public bool IsError => !Error.IsNullOrEmpty(); + + [JsonPropertyName("error")] + public string? Error { get; init; } + + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; init; } + + [JsonPropertyName("user_code")] + public string? UserCode { get; init; } + + [JsonPropertyName("device_code")] + public string? DeviceCode { get; init; } + + [JsonPropertyName("verification_uri")] + public string? VerificationUri { get; init; } + + [JsonPropertyName("verification_uri_complete")] + public string? VerificationUriComplete { get; init; } + + [JsonPropertyName("interval")] + public int? Interval { get; init; } + + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; init; } +} \ No newline at end of file From b3bfeb61680d33e1ad5b6c9f2884242d47fec6fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Sat, 21 Feb 2026 14:10:33 +0800 Subject: [PATCH 13/17] =?UTF-8?q?=E6=88=91=E8=A6=81=E9=99=AA=E4=BD=A0=20?= =?UTF-8?q?=E8=B5=B0=E4=B8=8B=E5=8E=BB=20=E4=B8=80=E7=9B=B4=E5=88=B0?= =?UTF-8?q?=E6=97=A0=E8=BE=B9=E5=A4=A9=E9=99=85=20=E6=84=9F=E8=B0=A2?= =?UTF-8?q?=E6=88=91=E4=BB=AC=E6=97=A0=E6=95=B0=E6=AC=A1=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=E5=BD=BC=E6=AD=A4=E7=9A=84=E5=8B=87=E6=B0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IdentityModel/OAuth/AuthorizeResult.cs | 25 +++ .../Minecraft/IdentityModel/OAuth/Client.cs | 145 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs new file mode 100644 index 000000000..7702809e6 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using PCL.Core.Utils.Exts; + +namespace PCL.Core.Minecraft.IdentityModel.OAuth; + +public record AuthorizeResult +{ + public bool IsError => !Error.IsNullOrEmpty(); + /// + /// 错误类型 (e.g. invalid_request) + /// + [JsonPropertyName("error")] public string? Error { get; init; } + /// + /// 描述此错误的文本 + /// + [JsonPropertyName("error_description")] public string? ErrorDescription { get; init; } + + // 不用 SecureString,因为这东西依赖 DPAPI,不是最佳实践 + + [JsonPropertyName("access_token")] public string? AccessToken { get; init; } + [JsonPropertyName("refresh_token")] public string? RefreshToken { get; init; } + [JsonPropertyName("id_token")] public string? IdToken { get; init; } + [JsonPropertyName("token_type")] public string? TokenType { get; init; } + [JsonPropertyName("expires_in")] public int? ExpiresIn { get; init; } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs new file mode 100644 index 000000000..84df1aa8b --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs @@ -0,0 +1,145 @@ +using System; +using System.Text; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading; + +namespace PCL.Core.Minecraft.IdentityModel.OAuth; + +/// +/// OAuth 客户端实现,配合 Polly 食用效果更佳 +/// +/// OAuth 参数 +public sealed class SimpleOAuthClient(OAuthClientOptions options):IOAuthClient +{ + /// + /// 获取授权 Url + /// + /// 访问权限列表 + /// 重定向 Url + /// + /// + /// + public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state,Dictionary? extData = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(options.Meta.AuthorizeEndpoint); + var sb = new StringBuilder(); + sb.Append(options.Meta.AuthorizeEndpoint); + sb.Append($"?response_type=code&scope={Uri.EscapeDataString(string.Join(" ", scopes))}"); + sb.Append($"&redirect_uri={Uri.EscapeDataString(redirectUri)}"); + sb.Append($"&client_id={options.ClientId}&state={state}"); + if (extData is null) return sb.ToString(); + foreach (var kvp in extData) + sb.Append($"&{kvp.Key}={Uri.EscapeDataString(kvp.Value)}"); + return sb.ToString(); + } + + /// + /// 使用授权代码获取 AccessToken + /// + /// 授权代码 + /// 附加属性,不应该包含必须参数和预定义字段 (e.g. client_id、grant_type) + /// + /// + public async Task AuthorizeWithCodeAsync( + string code,CancellationToken token,Dictionary? extData = null + ) + { + extData ??= new Dictionary(); + extData["client_id"] = options.ClientId; + extData["grant_type"] = "authorization_code"; + extData["code"] = code; + var client = options.GetClient.Invoke(); + using var content = new FormUrlEncodedContent(extData); + using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } + /// + /// 获取设备代码对 + /// + /// + /// + /// + /// + public async Task GetCodePairAsync + (string[] scopes,CancellationToken token, Dictionary? extData = null) + { + ArgumentException.ThrowIfNullOrEmpty(options.Meta.DeviceEndpoint); + var client = options.GetClient.Invoke(); + extData ??= new Dictionary(); + extData["scope"] = string.Join(" ", scopes); + extData["client_id"] = options.ClientId; + using var request = new HttpRequestMessage(HttpMethod.Post, options.Meta.DeviceEndpoint); + var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } + /// + /// 验证用户授权状态
+ /// 注:此方法不会检查是否过去了 Interval 秒,请自行处理 + ///
+ /// + /// + /// + /// + /// + public async Task AuthorizeWithDeviceAsync + (DeviceCodeData data,CancellationToken token,Dictionary? extData = null) + { + if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); + var client = options.GetClient.Invoke(); + extData ??= new Dictionary(); + extData["client_id"] = options.ClientId; + extData["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code"; + extData["device_code"] = data.DeviceCode!; + using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + using var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } + /// + /// 刷新登录 + /// + /// + /// + /// + /// + /// + public async Task AuthorizeWithSilentAsync + (AuthorizeResult data,CancellationToken token,Dictionary? extData = null) + { + var client = options.GetClient.Invoke(); + if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); + extData ??= new Dictionary(); + extData["refresh_token"] = data.RefreshToken!; + extData["grant_type"] = "refresh_token"; + extData["client_id"] = options.ClientId; + using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + using var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } +} \ No newline at end of file From 1f4a815f62e0d87cc61fdf4befacd22285f9330c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Sat, 21 Feb 2026 14:11:24 +0800 Subject: [PATCH 14/17] =?UTF-8?q?=E5=A5=BD=E7=9A=84=E5=A4=A9=E6=B0=94=20?= =?UTF-8?q?=E5=9D=8F=E7=9A=84=E8=BF=90=E6=B0=94=20=E9=83=BD=E6=98=AF?= =?UTF-8?q?=E5=80=BC=E5=BE=97=E5=BA=86=E7=A5=9D=E7=9A=84=E7=9B=B8=E9=81=87?= =?UTF-8?q?=20=E5=BD=93=E6=88=91=E4=BB=AC=E5=BC=80=E5=A7=8B=E7=9C=9F?= =?UTF-8?q?=E7=9A=84=E7=88=B1=E8=87=AA=E5=B7=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/OpenId/OpenIdClient.cs | 57 +++++++++++++ .../Extensions/OpenId/OpenIdMetaData.cs | 41 ++++++++++ .../Extensions/OpenId/OpenIdOptions.cs | 82 +++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdMetaData.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs new file mode 100644 index 000000000..fc2b88af5 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Documents; +using PCL.Core.Minecraft.IdentityModel.Extensions.Pkce; +using PCL.Core.Minecraft.IdentityModel.OAuth; +using PCL.Core.Utils.Exts; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; + +public class OpenIdClient(OpenIdOptions options):IOAuthClient +{ + private IOAuthClient? _client; + + public async Task InitialAsync(CancellationToken token,bool checkAddress = false) + { + var opt = await options.BuildOAuthOptionsAsync(token); + if (!checkAddress || opt.Meta.AuthorizeEndpoint.IsNullOrEmpty() || opt.Meta.DeviceEndpoint.IsNullOrEmpty()) + { + _client = options.EnablePkceSupport ? new PkceClient(opt) : new SimpleOAuthClient(opt); + return; + } + + throw new InvalidOperationException(); + } + + public string GetAuthorizeUrl(string[] scopes, string redirectUri, string state,Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return _client.GetAuthorizeUrl(scopes, redirectUri, state, extData); + } + + public async Task AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithCodeAsync(code, token, extData); + } + + public async Task GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.GetCodePairAsync(scopes, token, extData); + } + + public async Task AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithDeviceAsync(data, token, extData); + } + + public async Task AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithSilentAsync(data, token, extData); + } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdMetaData.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdMetaData.cs new file mode 100644 index 000000000..151a5de0a --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdMetaData.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; + + + +public record OpenIdMetadata +{ + [JsonPropertyName("issuer")] + public required string Issuer { get; init; } + + [JsonPropertyName("authorization_endpoint")] + public string? AuthorizationEndpoint { get; init; } + + [JsonPropertyName("device_authorization_endpoint")] + public string? DeviceAuthorizationEndpoint { get; init; } + + [JsonPropertyName("token_endpoint")] + public required string TokenEndpoint { get; init; } + + [JsonPropertyName("userinfo_endpoint")] + public required string UserInfoEndpoint { get; init; } + + [JsonPropertyName("registration_endpoint")] + public string? RegistrationEndpoint { get; init; } + + [JsonPropertyName("jwks_uri")] + public required string JwksUri { get; init; } + + [JsonPropertyName("scopes_supported")] + public required IReadOnlyList ScopesSupported { get; init; } + + [JsonPropertyName("subject_types_supported")] + public required IReadOnlyList SubjectTypesSupported { get; init; } + + [JsonPropertyName("id_token_signing_alg_values_supported")] + public required IReadOnlyList IdTokenSigningAlgValuesSupported { get; init; } + + +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs new file mode 100644 index 000000000..d61685e3c --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Tokens; +using PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken; +using PCL.Core.Minecraft.IdentityModel.OAuth; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; + +public record OpenIdOptions(Func GetHttpClient, string ConfigurationAddress) +{ + public string OpenIdDiscoveryAddress => ConfigurationAddress; + public required string ClientId + { + get; + set; + } + + public bool OnlyDeviceAuthorize { get; set; } + + public string? RedirectUri; + + public Dictionary? Headers { get; set; } + + public bool EnablePkceSupport { get; set; } = true; + public Func GetClient => GetHttpClient; + public OpenIdMetadata? Meta { get; set; } + + + + public virtual async Task InitiateAsync(CancellationToken token) + { + using var request = new HttpRequestMessage(HttpMethod.Get, OpenIdDiscoveryAddress); + if (Headers is not null) + foreach (var kvp in Headers) + { + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + } + + var requestTask = GetClient.Invoke().SendAsync(request, token); + using var response = await requestTask; + var task = response.Content.ReadAsStringAsync(token); + Meta = JsonSerializer.Deserialize(await task); + } + + public async Task GetSignatureKeyAsync(string kid,CancellationToken token) + { + if (Meta?.JwksUri is null) throw new InvalidOperationException(); + using var request = new HttpRequestMessage(HttpMethod.Get, Meta.JwksUri); + if (Headers is not null) + foreach (var kvp in Headers) + { + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + } + using var response = await GetClient.Invoke().SendAsync(request, token); + var result = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(token)); + return result?.Keys.Single(k => k.Kid == kid) + ?? throw new FormatException(); + } + + public virtual async Task BuildOAuthOptionsAsync(CancellationToken token) + { + await InitiateAsync(token); + if(!OnlyDeviceAuthorize) ArgumentException.ThrowIfNullOrEmpty(RedirectUri); + return new OAuthClientOptions + { + GetClient = GetClient, + ClientId = ClientId, + RedirectUri = OnlyDeviceAuthorize ? string.Empty:RedirectUri!, + Meta = new EndpointMeta + { + AuthorizeEndpoint = Meta?.AuthorizationEndpoint??string.Empty, + DeviceEndpoint = Meta?.DeviceAuthorizationEndpoint??string.Empty, + TokenEndpoint = Meta!.TokenEndpoint, + } + }; + } +} \ No newline at end of file From 0e15c9d7d1888c5c20c7261c477859227d610a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Sat, 21 Feb 2026 14:11:54 +0800 Subject: [PATCH 15/17] =?UTF-8?q?=E4=BC=A0=E8=AF=B4=E7=9A=84=E5=AE=9D?= =?UTF-8?q?=E8=97=8F=E5=B0=B1=E6=98=AF=E4=BD=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/JsonWebToken/JsonWebKeys.cs | 9 +++ .../Extensions/Pkce/PkceChallengeOptions.cs | 7 ++ .../Extensions/Pkce/PkceClient.cs | 58 ++++++++++++++ .../YggdrasilConnect/YggdrasilClient.cs | 77 +++++++++++++++++++ .../YggdrasilConnectMetaData.cs | 10 +++ .../YggdrasilConnect/YggdrasilOptions.cs | 53 +++++++++++++ 6 files changed, 214 insertions(+) create mode 100644 PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebKeys.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceChallengeOptions.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilConnectMetaData.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebKeys.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebKeys.cs new file mode 100644 index 000000000..e04862ee9 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebKeys.cs @@ -0,0 +1,9 @@ +using Microsoft.IdentityModel.Tokens; +using System.Text.Json.Serialization; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken; + +public record JsonWebKeys +{ + [JsonPropertyName("keys")] public required JsonWebKey[] Keys; +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceChallengeOptions.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceChallengeOptions.cs new file mode 100644 index 000000000..20d39ac80 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceChallengeOptions.cs @@ -0,0 +1,7 @@ +namespace PCL.Core.Minecraft.IdentityModel.Extensions.Pkce; + +public enum PkceChallengeOptions +{ + Sha256, + PlainText +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs new file mode 100644 index 000000000..ebdeb9f1a --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs @@ -0,0 +1,58 @@ +using System; +using PCL.Core.Utils.Exts; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using PCL.Core.Minecraft.IdentityModel.OAuth; +using PCL.Core.Utils.Hash; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.Pkce; + +/// +/// 带 PKCE 支持的客户端
+/// 此客户端并非线程安全,请勿在多个线程间共享示例 +///
+/// +public class PkceClient(OAuthClientOptions options):IOAuthClient +{ + private byte[] _ChallengeCode { get; set; } = new byte[32]; + private bool _isCallGetAuthorizeUrl; + public PkceChallengeOptions ChallengeMethod { get; private set; } = PkceChallengeOptions.Sha256; + private readonly SimpleOAuthClient _client = new(options); + public string GetAuthorizeUrl(string[] scopes, string redirectUri, string state, Dictionary? extData) + { + RandomNumberGenerator.Fill(_ChallengeCode); + var hash = SHA256Provider.Instance.ComputeHash(_ChallengeCode); + extData ??= []; + extData["code_challenge"] = hash; + extData["code_challenge_method"] = "S256"; + _isCallGetAuthorizeUrl = true; + return _client.GetAuthorizeUrl(scopes, redirectUri, state, extData); + } + + public async Task AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary? extData = null) + { + if (!_isCallGetAuthorizeUrl) throw new InvalidOperationException("Challenge code is invalid"); + var pkce = _ChallengeCode.FromBytesToB64UrlSafe(); + extData ??= []; + extData["code_verifier"] = pkce; + _isCallGetAuthorizeUrl = false; + return await _client.AuthorizeWithCodeAsync(code, token, extData); + } + + public async Task GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary? extData = null) + { + return await _client.GetCodePairAsync(scopes, token, extData); + } + + public async Task AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary? extData = null) + { + return await _client.AuthorizeWithDeviceAsync(data, token, extData); + } + + public async Task AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary? extData = null) + { + return await _client.AuthorizeWithSilentAsync(data, token, extData); + } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs new file mode 100644 index 000000000..3bb1cea9a --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; +using PCL.Core.Minecraft.IdentityModel.OAuth; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect; + +// Steven Qiu 说这东西完全就是 OpenId + 魔改了一部分,所以可以直接复用 OpenId 的逻辑 + +/// +/// +/// +public class YggdrasilClient:IOAuthClient +{ + + private OpenIdClient? _client; + + private YggdrasilOptions _options; + + public YggdrasilClient(YggdrasilOptions options) + { + _options = options; + } + /// + /// + /// + /// 当无法获取 ClientId 时抛出,调用方应该设置 ClientId 并重新实例化 OpenId Client + /// + public async Task InitialAsync(CancellationToken token) + { + _client = new OpenIdClient(_options); + await _client.InitialAsync(token); + } + /// + /// 获取授权端点地址 + /// + /// + /// + /// + /// + /// + /// 未调用 + public string GetAuthorizeUrl(string[] scopes, string redirectUri, string state, Dictionary? extData) + { + if (_client is null) throw new InvalidOperationException(); + return _client.GetAuthorizeUrl(scopes, redirectUri, state, extData); + } + + public async Task AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithCodeAsync(code, token, extData); + + } + + public async Task GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.GetCodePairAsync(scopes, token, extData); + + } + + public async Task AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithDeviceAsync(data, token, extData); + + } + + public async Task AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithSilentAsync(data, token, extData); + } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilConnectMetaData.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilConnectMetaData.cs new file mode 100644 index 000000000..539915432 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilConnectMetaData.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect; + +public record YggdrasilConnectMetaData: OpenIdMetadata +{ + [JsonPropertyName("shared_client_id")] + public string? SharedClientId { get; init; } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs new file mode 100644 index 000000000..cc7c9d036 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; +using PCL.Core.Minecraft.IdentityModel.OAuth; +using PCL.Core.Utils.Exts; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect; + +public record YggdrasilOptions:OpenIdOptions +{ + private string[] _scopesRequired = ["openid", "Yggdrasil.PlayerProfiles.Select", "Yggdrasil.Server.Join"]; + public YggdrasilOptions(Func getClient, string configurationAddress):base(getClient,configurationAddress) + { + + } + public override async Task InitiateAsync(CancellationToken token) + { + using var request = new HttpRequestMessage(HttpMethod.Get, OpenIdDiscoveryAddress); + if (Headers is not null) + foreach (var kvp in Headers) + { + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + } + + using var response = await GetClient.Invoke().SendAsync(request, token); + Meta = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(token)); + if (Meta is null) throw new InvalidOperationException(); + if (_scopesRequired.Except(Meta.ScopesSupported).Any()) throw new InvalidOperationException(); + } + + public override async Task BuildOAuthOptionsAsync(CancellationToken token) + { + await InitiateAsync(token); + if (Meta is YggdrasilConnectMetaData meta) + { + var options = await base.BuildOAuthOptionsAsync(token); + if (!options.ClientId.IsNullOrEmpty()) return options; + if (meta is null) throw new InvalidOperationException(); + if (!meta.SharedClientId.IsNullOrEmpty()) + { + options.ClientId = meta.SharedClientId; + } + + throw new ArgumentException("ClientId"); + } + + throw new InvalidCastException($"Can not cast {Meta?.GetType().FullName} to YggdrasilConnectMetaData"); + } +} \ No newline at end of file From 53917bf466f3350696783063e9781fa196ccb1b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Sat, 21 Feb 2026 14:20:23 +0800 Subject: [PATCH 16/17] =?UTF-8?q?Tun=20on=20the=20light=20=E8=B7=9F?= =?UTF-8?q?=E4=B8=8A=E8=BF=99=E8=8A=82=E6=8B=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小梦灵添加了点 Nuget Ref --- PCL.Core/PCL.Core.csproj | 178 +++++++++++++++++++-------------------- 1 file changed, 89 insertions(+), 89 deletions(-) diff --git a/PCL.Core/PCL.Core.csproj b/PCL.Core/PCL.Core.csproj index 164717e14..92df4f647 100644 --- a/PCL.Core/PCL.Core.csproj +++ b/PCL.Core/PCL.Core.csproj @@ -1,90 +1,90 @@ - - - - PCL.Core - PCL.Core - PCL Community 为 PCL 开发的启动器核心库 - PCL Community - PCL.Core - Copyright © PCL Community - 1.0.0.0 - 1.0.0.0 - - - Debug - AnyCPU - Debug;CI;Release;Beta - AnyCPU;x64;ARM64 - {A0C2209D-64FB-4C11-9459-8E86304B6F94} - PCL.Core - net8.0-windows - true - true - true - 14.0 - enable - prompt - 4 - bin\$(Configuration)-$(Platform)\ - $(Platform) - - - - true - full - false - DEBUG;TRACE - CI;TRACE - - - none - true - RELEASE;PUBLISH - BETA;PUBLISH - - - - - - - - - - - - - - - - - - - - - - - - - - - - - false - false - false - false - false - false - - - - - - - - - - - - + + + + PCL.Core + PCL.Core + PCL Community 为 PCL 开发的启动器核心库 + PCL Community + PCL.Core + Copyright © PCL Community + 1.0.0.0 + 1.0.0.0 + + + Debug + AnyCPU + Debug;CI;Release;Beta + AnyCPU;x64;ARM64 + {A0C2209D-64FB-4C11-9459-8E86304B6F94} + PCL.Core + net8.0-windows + true + true + true + 14.0 + enable + prompt + 4 + bin\$(Configuration)-$(Platform)\ + $(Platform) + + + + true + full + false + DEBUG;TRACE + CI;TRACE + + + none + true + RELEASE;PUBLISH + BETA;PUBLISH + + + + + + + + + + + + + + + + + + + + + + + + + + + + + false + false + false + false + false + false + + + + + + + + + + + + \ No newline at end of file From d446d78914002b5397f4442405a59fe7f8d656ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Sat, 21 Feb 2026 15:39:01 +0800 Subject: [PATCH 17/17] =?UTF-8?q?Dance=20all=20right=20=E6=A2=A6=E9=A9=B1?= =?UTF-8?q?=E6=95=A3=E9=98=B4=E9=9C=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PCL.Core/PCL.Core.csproj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/PCL.Core/PCL.Core.csproj b/PCL.Core/PCL.Core.csproj index 92df4f647..900fb8508 100644 --- a/PCL.Core/PCL.Core.csproj +++ b/PCL.Core/PCL.Core.csproj @@ -66,6 +66,8 @@ + + @@ -87,4 +89,4 @@ - \ No newline at end of file +