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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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/24] =?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 + From 8b08e122ef5757b0f525f144a440cc37257f6614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E5=A5=9A=E6=A2=A6=E7=81=B5?= Date: Mon, 23 Feb 2026 14:38:30 +0800 Subject: [PATCH 18/24] =?UTF-8?q?=E8=A6=81=E5=85=A8=E4=B8=96=E7=95=8C?= =?UTF-8?q?=E5=96=9D=E5=BD=A9=20=E6=90=AD=E5=BB=BA=E6=88=91=E7=9A=84?= =?UTF-8?q?=E8=88=9E=E5=8F=B0=20=E5=B0=B1=E7=8E=B0=E5=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IdentityModel/Yggdrasil/Agent.cs | 14 +++++++ .../IdentityModel/Yggdrasil/Client.cs | 23 +++++++++++ .../IdentityModel/Yggdrasil/Profile.cs | 41 +++++++++++++++++++ .../Yggdrasil/YggdrasilCredential.cs | 17 ++++++++ 4 files changed, 95 insertions(+) create mode 100644 PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs new file mode 100644 index 000000000..7168f079f --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; + +public record Agent +{ + [JsonPropertyName("name")] public string Name { get; init; } = "minecraft"; + [JsonPropertyName("version")] public int Version { get; init; } = 1; +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs new file mode 100644 index 000000000..04f5badb0 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; + +public class YggdrasilLegacyClient +{ + public string? User; + public string? Password; + public string? AccessToken; + public YggdrasilLegacyClient(string username,string password) + { + + } + + public async Task AuthenticateAsync() + { + + } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs new file mode 100644 index 000000000..5f8f5234e --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; + +public record Profile +{ + [JsonPropertyName("id")] public required string Id { get; init; } + [JsonPropertyName("name")] public required string Name { get; init; } +} + +public record PlayerProperty +{ + [JsonPropertyName("name")] public required string Name { get; init; } + [JsonPropertyName("value")] public required string Value { get; init; } + [JsonPropertyName("signature")] public string? Signature { get; init; } +} + +public record PlayerTextureProperty +{ + [JsonPropertyName("timestamp")] public required long Timestamp { get; init; } + [JsonPropertyName("profileId")] public required string ProfileId { get; init; } + [JsonPropertyName("profileName")] public required string ProfileName { get; init; } + [JsonPropertyName("textures")] public required PlayerTextures Textures { get; init; } +} + +public record PlayerTextures +{ + [JsonPropertyName("skin")] public required PlayerTexture Skin { get; init; } + [JsonPropertyName("cape")] public required PlayerTexture Cape { get; init; } +} + +public record PlayerTexture +{ + [JsonPropertyName("Url")] public required string Url { get; init; } + [JsonPropertyName("metadata")] public required PlayerTextureMetadata Metadata { get; init; } +} + +public record PlayerTextureMetadata +{ + [JsonPropertyName("model")] public required string Model { get; init; } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs new file mode 100644 index 000000000..b8bdd3ec3 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; + +public record YggdrasilCredential +{ + [JsonPropertyName("username")] public required string User { get; init; } + [JsonPropertyName("password")] public required string Password { get; init; } + [JsonPropertyName("agent")] public required Agent Agent = new(); + [JsonPropertyName("requestUser")] public bool RequestUser { get; set; } +} + +public record YggdrasilAutnenticationResult +{ + [JsonPropertyName("accessToken")] public required string AccessToken { get; set; } + [JsonPropertyName("clientToken")] public required string ClientToken { get; set; } +} \ No newline at end of file From 385e990a4d2f66375c8bb46ba1fe382296170fe8 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, 24 Feb 2026 00:17:58 +0800 Subject: [PATCH 19/24] =?UTF-8?q?=E7=A7=8D=E5=AD=90=E5=9C=A8=E5=8F=91?= =?UTF-8?q?=E8=8A=BD=E5=83=8F=E6=A2=A6=E6=83=B3=E7=BB=99=E5=87=BA=E5=9B=9E?= =?UTF-8?q?=E7=AD=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/JsonWebToken/JsonWebToken.cs | 64 ++++++++ .../Extensions/OpenId/OpenIdClient.cs | 55 ++++++- .../Extensions/OpenId/OpenIdOptions.cs | 59 +++++-- .../Extensions/Pkce/PkceClient.cs | 43 +++++- .../YggdrasilConnect/YggdrasilClient.cs | 46 ++++-- .../YggdrasilConnect/YggdrasilOptions.cs | 24 ++- .../IdentityModel/OAuth/AuthorizeResult.cs | 15 ++ .../Minecraft/IdentityModel/OAuth/Client.cs | 2 +- .../IdentityModel/OAuth/ClientOptions.cs | 12 +- .../IdentityModel/OAuth/DeviceCodeData.cs | 32 +++- .../IdentityModel/OAuth/EndpointMeta.cs | 9 ++ .../IdentityModel/Yggdrasil/Agent.cs | 3 + .../IdentityModel/Yggdrasil/Client.cs | 145 +++++++++++++++++- .../IdentityModel/Yggdrasil/Options.cs | 30 ++++ .../IdentityModel/Yggdrasil/Profile.cs | 49 +++++- .../Yggdrasil/YggdrasilCredential.cs | 39 ++++- 16 files changed, 567 insertions(+), 60 deletions(-) create mode 100644 PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs create mode 100644 PCL.Core/Minecraft/IdentityModel/Yggdrasil/Options.cs diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs new file mode 100644 index 000000000..6b6862736 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Security; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Tokens; +using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken; + +/// +/// Json Web Token 类 +/// +/// +/// +public class JsonWebToken(string token,OpenIdMetadata meta) +{ + public delegate SecurityToken? TokenValidateCallback(OpenIdMetadata metadata,string token, JsonWebKey key,string clientId); + + public TokenValidateCallback SecurityTokenValidateCallback { get; set; } = static (meta,token, key,clientId) => + { + var handler = new JwtSecurityTokenHandler(); + var parameter = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = meta.Issuer, + ValidateAudience = true, + ValidAudience = clientId, + }; + handler.ValidateToken(token, parameter, out var secToken); + return secToken; + }; + + private bool _verified; + /// + /// 尝试读取 Token 中的字段 + /// + /// 是否允许在未验证的情况下读取字段,若为 false,当 Token 未验证时将抛出异常 + /// + /// + /// 未调用 + public T ReadTokenPayload(bool allowUnverifyToken = false) + { + throw new NotImplementedException(); + } + /// + /// 读取 Token 头 + /// + /// + /// + public T ReadTokenHeader() + { + throw new NotImplementedException(); + } + /// + /// 对 Token 进行签名验证
+ /// 默认情况下仅对签名、iss、nbf、exp 进行验证,如果需要更细粒度验证,请设置 + ///
+ /// + public SecurityToken? TryVerifySignature(JsonWebKey key,string clientId) + { + return SecurityTokenValidateCallback.Invoke(meta, token, key, clientId); + } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs index fc2b88af5..7ca53fca8 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs @@ -12,8 +12,13 @@ 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) + /// + /// 初始化并从网络加载 OpenId 配置 + /// + /// + /// + /// 当要求检查地址并不存在任何授权端点时,将触发此错误 + public async Task InitializeAsync(CancellationToken token,bool checkAddress = false) { var opt = await options.BuildOAuthOptionsAsync(token); if (!checkAddress || opt.Meta.AuthorizeEndpoint.IsNullOrEmpty() || opt.Meta.DeviceEndpoint.IsNullOrEmpty()) @@ -24,31 +29,67 @@ public async Task InitialAsync(CancellationToken token,bool checkAddress = false throw new InvalidOperationException(); } - + /// + /// 获取授权代码流地址 + /// + /// 权限列表 + /// 重定向 Uri + /// + /// 扩展数据 + /// + /// 未调用 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); } - + /// + /// 使用授权代码兑换 Token + /// + /// + /// + /// + /// + /// 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(); diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs index d61685e3c..bf7f348d7 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs @@ -13,26 +13,51 @@ namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; public record OpenIdOptions(Func GetHttpClient, string ConfigurationAddress) { + /// + /// OpenId Discovery 地址 + /// public string OpenIdDiscoveryAddress => ConfigurationAddress; + /// + /// 客户端 ID(必须设置) + /// public required string ClientId { get; set; } + // 为了让 YggdrasilConnect Client 复用代码做的逻辑 + + /// + /// 是否只使用设备代码流授权 + /// public bool OnlyDeviceAuthorize { get; set; } - - public string? RedirectUri; - + /// + /// 回调 Uri + /// + public string? RedirectUri { get; set; } + /// + /// 发送 HTTP 请求时设置的请求头,仅适用于请求头(丢到 HttpRequestMessage 不会报错的那种) + /// public Dictionary? Headers { get; set; } - + /// + /// 是否启用 PKCE 支持,默认启用 + /// public bool EnablePkceSupport { get; set; } = true; + /// + /// 获取 HttpClient,生命周期由调用方管理 + /// public Func GetClient => GetHttpClient; - public OpenIdMetadata? Meta { get; set; } + /// + /// OpenId 元数据,请勿自行设置此属性,而是应该调用 + /// + public OpenIdMetadata? Meta { get; internal set; } - - - public virtual async Task InitiateAsync(CancellationToken token) + /// + /// 从互联网拉取 OpenID 配置信息 + /// + /// + public virtual async Task InitializeAsync(CancellationToken token) { using var request = new HttpRequestMessage(HttpMethod.Get, OpenIdDiscoveryAddress); if (Headers is not null) @@ -46,7 +71,14 @@ public virtual async Task InitiateAsync(CancellationToken token) var task = response.Content.ReadAsStringAsync(token); Meta = JsonSerializer.Deserialize(await task); } - + /// + /// 获取 Json Web Key + /// + /// 密钥 ID + /// + /// + /// 未调用 + /// 找不到 Jwk 或 Jwk 配置无效 public async Task GetSignatureKeyAsync(string kid,CancellationToken token) { if (Meta?.JwksUri is null) throw new InvalidOperationException(); @@ -61,10 +93,15 @@ public async Task GetSignatureKeyAsync(string kid,CancellationToken return result?.Keys.Single(k => k.Kid == kid) ?? throw new FormatException(); } - + /// + /// 构建 OAuth 客户端配置 + /// + /// + /// + /// 未调用 public virtual async Task BuildOAuthOptionsAsync(CancellationToken token) { - await InitiateAsync(token); + if (Meta is null) throw new InvalidOperationException(); if(!OnlyDeviceAuthorize) ArgumentException.ThrowIfNullOrEmpty(RedirectUri); return new OAuthClientOptions { diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs index ebdeb9f1a..e3a35fc8f 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs @@ -18,19 +18,38 @@ public class PkceClient(OAuthClientOptions options):IOAuthClient { private byte[] _ChallengeCode { get; set; } = new byte[32]; private bool _isCallGetAuthorizeUrl; + /// + /// 设置验证方法,支持 PlainText 和 SHA256 + /// 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"; + extData["code_challenge"] = ChallengeMethod == PkceChallengeOptions.Sha256 + ? SHA256Provider.Instance.ComputeHash(_ChallengeCode) + : _ChallengeCode.FromBytesToB64UrlSafe(); + extData["code_challenge_method"] = ChallengeMethod == PkceChallengeOptions.Sha256 ? "S256":"plain"; _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"); @@ -40,12 +59,24 @@ public string GetAuthorizeUrl(string[] scopes, string redirectUri, string state, _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); diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs index 3bb1cea9a..029797c01 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs @@ -24,14 +24,14 @@ public YggdrasilClient(YggdrasilOptions options) _options = options; } /// - /// + /// 初始化并拉取网络配置 /// - /// 当无法获取 ClientId 时抛出,调用方应该设置 ClientId 并重新实例化 OpenId Client + /// 当无法获取 ClientId 时抛出,调用方应该设置 ClientId 并重新实例化 Client /// - public async Task InitialAsync(CancellationToken token) + public async Task InitializeAsync(CancellationToken token) { _client = new OpenIdClient(_options); - await _client.InitialAsync(token); + await _client.InitializeAsync(token,true); } /// /// 获取授权端点地址 @@ -41,34 +41,62 @@ public async Task InitialAsync(CancellationToken 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(); diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs index cc7c9d036..a3242e302 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs @@ -17,7 +17,15 @@ public YggdrasilOptions(Func getClient, string configurationAddress) { } - public override async Task InitiateAsync(CancellationToken token) + + // 重写这个鬼方法是因为 Yggdrasil Connect 有要求( + + /// + /// 拉取 Yggdrasil 配置 + /// + /// + /// + public override async Task InitializeAsync(CancellationToken token) { using var request = new HttpRequestMessage(HttpMethod.Get, OpenIdDiscoveryAddress); if (Headers is not null) @@ -31,10 +39,16 @@ public override async Task InitiateAsync(CancellationToken token) if (Meta is null) throw new InvalidOperationException(); if (_scopesRequired.Except(Meta.ScopesSupported).Any()) throw new InvalidOperationException(); } - + /// + /// 构建 OAuth 客户端选项 + /// + /// + /// + /// 未调用 + /// + /// public override async Task BuildOAuthOptionsAsync(CancellationToken token) { - await InitiateAsync(token); if (Meta is YggdrasilConnectMetaData meta) { var options = await base.BuildOAuthOptionsAsync(token); @@ -45,9 +59,9 @@ public override async Task BuildOAuthOptionsAsync(Cancellati options.ClientId = meta.SharedClientId; } - throw new ArgumentException("ClientId"); + throw new ArgumentException(); } - throw new InvalidCastException($"Can not cast {Meta?.GetType().FullName} to YggdrasilConnectMetaData"); + throw new InvalidCastException(); } } \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs index 7702809e6..4b46d4508 100644 --- a/PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs @@ -17,9 +17,24 @@ public record AuthorizeResult // 不用 SecureString,因为这东西依赖 DPAPI,不是最佳实践 + /// + /// 访问令牌 + /// [JsonPropertyName("access_token")] public string? AccessToken { get; init; } + /// + /// 刷新令牌 + /// [JsonPropertyName("refresh_token")] public string? RefreshToken { get; init; } + /// + /// ID Token + /// [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 index 84df1aa8b..8c5c0c480 100644 --- a/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs @@ -37,7 +37,7 @@ public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state,Di } /// - /// 使用授权代码获取 AccessToken + /// 使用授权代码获取令牌 /// /// 授权代码 /// 附加属性,不应该包含必须参数和预定义字段 (e.g. client_id、grant_type) diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs index 0596dc4cb..24835ee20 100644 --- a/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs @@ -7,12 +7,20 @@ 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; } - + /// + /// 重定向 Uri + /// public required string RedirectUri { get; set; } + /// + /// 客户端 ID + /// 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 index 877e6ab36..0dd8b2f22 100644 --- a/PCL.Core/Minecraft/IdentityModel/OAuth/DeviceCodeData.cs +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/DeviceCodeData.cs @@ -6,28 +6,44 @@ 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; } - + /// + /// 验证 Uri + /// [JsonPropertyName("verification_uri")] public string? VerificationUri { get; init; } - + /// + /// 验证 Uri (自动填充代码) + /// [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 diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs index 76d37acf1..c26c9ec8a 100644 --- a/PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs @@ -2,7 +2,16 @@ 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/Yggdrasil/Agent.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs index 7168f079f..06ed1a007 100644 --- a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs @@ -7,6 +7,9 @@ namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; +/// +/// Yggdrasil Agent +/// public record Agent { [JsonPropertyName("name")] public string Name { get; init; } = "minecraft"; diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs index 04f5badb0..85c6bd82f 100644 --- a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs @@ -1,23 +1,154 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; +using System.Net.Http; using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; using System.Threading.Tasks; namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; -public class YggdrasilLegacyClient +/// +/// 提供 Yggdrasil 传统认证支持 +/// +/// 认证参数 +public sealed class YggdrasilLegacyClient(YggdrasilLegacyAuthenticateOptions options) { - public string? User; - public string? Password; - public string? AccessToken; - public YggdrasilLegacyClient(string username,string password) + /// + /// 异步向服务器发送一次登录请求 + /// + /// + /// 认证结果 + /// 用户名或密码无效 + public async Task AuthenticateAsync(CancellationToken token) { - + ArgumentException.ThrowIfNullOrEmpty(options.Username); + ArgumentException.ThrowIfNullOrEmpty(options.Password); + + var credential = new YggdrasilCredential + { + User = options.Username, + Password = options.Password, + }; + var address = $"{options.YggdrasilApiLocation}/authserver/authenticate"; + using var request = new HttpRequestMessage(HttpMethod.Post, address); + if(options.RequestHeaders is not null) + foreach (var kvp in options.RequestHeaders) + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + + using var content = + new StringContent(JsonSerializer.Serialize(options), Encoding.UTF8, "application/json"); + request.Content = content; + using var response = await options.GetClient.Invoke().SendAsync(request,token); + return + JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(token)); + + } + /// + /// 异步向服务器发送一次刷新请求 + /// + /// + /// 如果需要选择角色,请填写此参数 + public async Task RefreshAsync(CancellationToken token,Profile? seleectedProfile) + { + ArgumentException.ThrowIfNullOrEmpty(options.AccessToken); + + var refreshData = new YggdrasilRefresh() + { + AccessToken = options.AccessToken + }; + if (seleectedProfile is not null) refreshData.SelectedProfile = seleectedProfile; + + var address = $"{options.YggdrasilApiLocation}/authserver/refresh"; + + using var request = new HttpRequestMessage(HttpMethod.Post, address); + if(options.RequestHeaders is not null) + foreach (var kvp in options.RequestHeaders) + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + using var content = new StringContent( + JsonSerializer.Serialize(refreshData), Encoding.UTF8, "application/json"); + request.Content = content; + using var response = await options.GetClient.Invoke().SendAsync(request, token); + return JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(token)); } + /// + /// 异步向服务器发送一次验证请求 + /// + /// + /// + public async Task ValidateAsync(CancellationToken token) + { + ArgumentException.ThrowIfNullOrEmpty(options.AccessToken); - public async Task AuthenticateAsync() + var validateData = new YggdrasilRefresh() + { + AccessToken = options.AccessToken + }; + var address = $"{options.YggdrasilApiLocation}/authserver/invalidate"; + + using var request = new HttpRequestMessage(HttpMethod.Post, address); + if(options.RequestHeaders is not null) + foreach (var kvp in options.RequestHeaders) + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + + using var content = new StringContent( + JsonSerializer.Serialize(validateData), Encoding.UTF8, "application/json"); + request.Content = content; + using var response = await options.GetClient.Invoke().SendAsync(request, token); + return response.StatusCode == HttpStatusCode.NoContent; + } + + /// + /// 异步向服务器发送一次注销请求 + /// + /// + public async Task InvalidateAsync(CancellationToken token) { + ArgumentException.ThrowIfNullOrEmpty(options.AccessToken); + var validateData = new YggdrasilRefresh() + { + AccessToken = options.AccessToken + }; + var address = $"{options.YggdrasilApiLocation}/authserver/invalidate"; + + using var request = new HttpRequestMessage(HttpMethod.Post, address); + if(options.RequestHeaders is not null) + foreach (var kvp in options.RequestHeaders) + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + + using var content = new StringContent( + JsonSerializer.Serialize(validateData), Encoding.UTF8, "application/json"); + request.Content = content; + await options.GetClient.Invoke().SendAsync(request, token); + } + /// + /// 异步向服务器发送登出请求
+ /// 这会立刻注销所有会话,无论当前会话是否属于调用方 + ///
+ /// + /// + public async Task<(bool IsSuccess,string ErrorDescription)> SignOutAsync(CancellationToken token) + { + // 不想写 Model 了,就这样吧(趴 + var signoutData = new JsonObject + { + ["username"] = options.Username, + ["password"] = options.Password + }.ToJsonString(); + var address = $"{options.YggdrasilApiLocation}/authserver/signout"; + + using var request = new HttpRequestMessage(HttpMethod.Post, address); + if(options.RequestHeaders is not null) + foreach (var kvp in options.RequestHeaders) + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + using var content = new StringContent(signoutData, Encoding.UTF8, "application/json"); + request.Content = content; + using var response = await options.GetClient.Invoke().SendAsync(request, token); + var data = JsonNode.Parse(await response.Content.ReadAsStringAsync(token)); + return (response.StatusCode == HttpStatusCode.NoContent, data?["errorMessage"]?.ToString()!); } } \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Options.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Options.cs new file mode 100644 index 000000000..592dfd784 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Options.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; + +public record YggdrasilLegacyAuthenticateOptions +{ + /// + /// API 基地址 (e.g. https://api.example.com/api/yggdrasil) + /// + public required string YggdrasilApiLocation { get; set; } + /// + /// 用户名 + /// + public string? Username { get; set; } + /// + /// 密码 + /// + public string? Password { get; set; } + /// + /// 访问令牌 + /// + public string? AccessToken { get; set; } + public required Func GetClient { get; set; } + /// + /// 请求头 + /// + public Dictionary? RequestHeaders { get; set; } +} diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs index 5f8f5234e..2668be6ab 100644 --- a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs @@ -2,40 +2,87 @@ namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; + public record Profile { + /// + /// UUID + /// [JsonPropertyName("id")] public required string Id { get; init; } - [JsonPropertyName("name")] public required string Name { get; init; } + /// + /// 档案名称 + /// + [JsonPropertyName("name")] public string? Name { get; init; } + /// + /// 属性信息 + /// + [JsonPropertyName("properties")] public PlayerProperty[] Properties { get; init; } } public record PlayerProperty { + /// + /// 属性名称 + /// [JsonPropertyName("name")] public required string Name { get; init; } + /// + /// 属性值 + /// [JsonPropertyName("value")] public required string Value { get; init; } + /// + /// 数字签名 + /// [JsonPropertyName("signature")] public string? Signature { get; init; } } public record PlayerTextureProperty { + /// + /// Unix 时间戳 + /// [JsonPropertyName("timestamp")] public required long Timestamp { get; init; } + /// + /// 所有者的 UUID + /// [JsonPropertyName("profileId")] public required string ProfileId { get; init; } + /// + /// 所有者名称 + /// [JsonPropertyName("profileName")] public required string ProfileName { get; init; } + /// + /// 材质信息 + /// [JsonPropertyName("textures")] public required PlayerTextures Textures { get; init; } } public record PlayerTextures { + /// + /// 皮肤 + /// [JsonPropertyName("skin")] public required PlayerTexture Skin { get; init; } + /// + /// 披风 + /// [JsonPropertyName("cape")] public required PlayerTexture Cape { get; init; } } public record PlayerTexture { + /// + /// 材质地址 + /// [JsonPropertyName("Url")] public required string Url { get; init; } + /// + /// 元数据 + /// [JsonPropertyName("metadata")] public required PlayerTextureMetadata Metadata { get; init; } } public record PlayerTextureMetadata { + /// + /// 模型信息 (e.g. Steven -> default, Alex -> Slim) + /// [JsonPropertyName("model")] public required string Model { get; init; } } \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs index b8bdd3ec3..415cab4a1 100644 --- a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using PCL.Core.Link.Scaffolding.Client.Models; namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; @@ -6,12 +7,44 @@ public record YggdrasilCredential { [JsonPropertyName("username")] public required string User { get; init; } [JsonPropertyName("password")] public required string Password { get; init; } - [JsonPropertyName("agent")] public required Agent Agent = new(); + [JsonPropertyName("agent")] public Agent Agent = new(); [JsonPropertyName("requestUser")] public bool RequestUser { get; set; } } -public record YggdrasilAutnenticationResult +public record YggdrasilAuthenticateResult +{ + /// + /// 错误类型 + /// + [JsonPropertyName("error")] public string? Error { get; init; } + /// + /// 错误消息 + /// + [JsonPropertyName("errorMessage")] public string? ErrorMessage { get; init; } + /// + /// 访问令牌 + /// + [JsonPropertyName("accessToken")] public string? AccessToken { get; init; } + /// + /// 客户端令牌,基本没用 + /// + [JsonPropertyName("clientToken")] public string? ClientToken { get; init; } + /// + /// 选择的档案 + /// + [JsonPropertyName("selectedProfile")] public Profile? SelectedProfile { get; init; } + /// + /// 可用档案 + /// + [JsonPropertyName("availableProfiles")] public required Profile[]? AvailableProfiles { get; init; } + /// + /// 用户信息 + /// + [JsonPropertyName("user")] public Profile? User; +} + +public record YggdrasilRefresh { [JsonPropertyName("accessToken")] public required string AccessToken { get; set; } - [JsonPropertyName("clientToken")] public required string ClientToken { get; set; } + [JsonPropertyName("selectedProfile")] public Profile? SelectedProfile { get; set; } } \ No newline at end of file From 72a9077167309ca181b6cca44c7417c5e6cc4244 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, 24 Feb 2026 01:48:14 +0800 Subject: [PATCH 20/24] =?UTF-8?q?I=20will=20be=20paying=20=E4=B8=80?= =?UTF-8?q?=E5=AE=9A=E4=BC=9A=E5=AE=9E=E7=8E=B0=E5=AE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/OpenId/OpenIdClient.cs | 5 +- .../Extensions/OpenId/OpenIdOptions.cs | 6 +- .../Extensions/Pkce/PkceClient.cs | 159 +++++++++++++++++- .../YggdrasilConnect/YggdrasilClient.cs | 5 +- .../YggdrasilConnect/YggdrasilOptions.cs | 4 - .../Minecraft/IdentityModel/OAuth/Client.cs | 5 +- .../IdentityModel/OAuth/IOAuthClient.cs | 2 +- .../IdentityModel/Yggdrasil/Client.cs | 24 ++- .../IdentityModel/Yggdrasil/Options.cs | 2 +- .../IdentityModel/Yggdrasil/Profile.cs | 2 +- 10 files changed, 177 insertions(+), 37 deletions(-) diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs index 7ca53fca8..fcbf57090 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs @@ -33,15 +33,14 @@ public async Task InitializeAsync(CancellationToken token,bool checkAddress = fa /// 获取授权代码流地址 /// /// 权限列表 - /// 重定向 Uri /// /// 扩展数据 /// /// 未调用 - public string GetAuthorizeUrl(string[] scopes, string redirectUri, string state,Dictionary? extData = null) + public string GetAuthorizeUrl(string[] scopes, string state,Dictionary? extData = null) { if (_client is null) throw new InvalidOperationException(); - return _client.GetAuthorizeUrl(scopes, redirectUri, state, extData); + return _client.GetAuthorizeUrl(scopes, state, extData); } /// /// 使用授权代码兑换 Token diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs index bf7f348d7..5eb623e3f 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs @@ -11,12 +11,12 @@ namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; -public record OpenIdOptions(Func GetHttpClient, string ConfigurationAddress) +public record OpenIdOptions { /// /// OpenId Discovery 地址 /// - public string OpenIdDiscoveryAddress => ConfigurationAddress; + public required string OpenIdDiscoveryAddress { get; set; } /// /// 客户端 ID(必须设置) /// @@ -47,7 +47,7 @@ public required string ClientId /// /// 获取 HttpClient,生命周期由调用方管理 /// - public Func GetClient => GetHttpClient; + public Func GetClient { get; set; } /// /// OpenId 元数据,请勿自行设置此属性,而是应该调用 /// diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs index e3a35fc8f..1a836bbd3 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs @@ -27,20 +27,19 @@ public class PkceClient(OAuthClientOptions options):IOAuthClient /// 获取授权地址 /// /// - /// /// /// /// - public string GetAuthorizeUrl(string[] scopes, string redirectUri, string state, Dictionary? extData) + public string GetAuthorizeUrl(string[] scopes, string state, Dictionary? extData) { RandomNumberGenerator.Fill(_ChallengeCode); extData ??= []; extData["code_challenge"] = ChallengeMethod == PkceChallengeOptions.Sha256 - ? SHA256Provider.Instance.ComputeHash(_ChallengeCode) + ? SHA256Provider.Instance.ComputeHash(_ChallengeCode).ToHexString() : _ChallengeCode.FromBytesToB64UrlSafe(); extData["code_challenge_method"] = ChallengeMethod == PkceChallengeOptions.Sha256 ? "S256":"plain"; _isCallGetAuthorizeUrl = true; - return _client.GetAuthorizeUrl(scopes, redirectUri, state, extData); + return _client.GetAuthorizeUrl(scopes, state, extData); } /// /// 使用授权代码兑换令牌 @@ -86,4 +85,154 @@ public string GetAuthorizeUrl(string[] scopes, string redirectUri, string state, { return await _client.AuthorizeWithSilentAsync(data, token, extData); } -} \ No newline at end of file +} + +/* + * + *本 PR 旨在提供规范的身份认证组件,以解决 PCL 内部的身份认证实现不规范还一堆 Bug 完全没有可维护性的问题 + +## ToDo List + +- [x] 实现 OAuth 认证(RFC 6749/RFC 8628) +- [x] 支持 OAuth 扩展实现(PKCE) +- [x] 支持 OpenId Connect(精简版) +- [x] 支持 Yggdrasil Connect +- [x] 支持传统 Yggdrasil + +# 用例 + +>[!IMPORTANT] +> ## IdentityModel 不会尝试处理任何错误 +> +> +> 因为其设计目标是作为协议传输层将调用方提供的数据转换为标准数据格式(类似 HttpClient) +> 错误需要调用方自行处理 + +## 普通 OAuth + +### 初始化 + +```csharp +var option = new OAuthClientOptions() +{ + ClientId = "0712", + GetClient = () => _client, + Headers = new(){ + ["User-Agent"] = "PCL-CE/2.14.2" + }, + Meta = new EndpointMeta + { + AuthorizeEndpoint = "https://open.example.com/oauth/v2.0/authorize", + DeviceEndpoint = "https://open.example.com/oauth/v2.0/device", + TokenEndpoint = "https://open.example.com/oauth/v2.0/token" + }, + RedirectUri = "http://localhost:7120/oauth/callback" +}; + +var client = new SimpleOAuthClient(option); +``` + +>[!Tip] +> +> ### PKCE 扩展 +> +> 如果需要 PKCE 扩展支持,,请使用 PkceClient 而不是 SimpleOAuthClient + +### 授权代码流 + +获取授权 Url + +```csharp +var authorizeUri = client.GetAuthorizeUrl(["offline_access"],"20120712"); +``` +使用授权代码兑换令牌 + +```csharp +var result = await client.AuthorizeWithCodeAsync("",CancellationToken.None); +``` + +### 设备代码流 + +获取代码对 + +```csharp +var data = await client.GetCodePairAsync(["offline_access"], CancellationToken.None); +``` + +```csharp +var data = await client.AuthorizeWithDeviceAsync(data, CancellationToken.None); +``` + +>[!IMPORTANT] +> ### AuthorizeWithDeviceAsync 仅会发送一次请求(不会轮询) +> +> 你可以配合 Polly 做重试,或者自己糊也行,但绝对不能只调用一次 + +### 刷新登录 + +```csharp +await client.AuthorizeWithSilentAsync(data, CancellationToken.None); +``` + +>[!TIP] +> ### 扩展数据支持 +> +> 如果某一个协议基于 OAuth 但需要提供更多的请求载荷,你可以设置每个方法的 extData 参数(字典)并提供对应的数据 + +>[!WARNING] +> +> ### 不要填写预定义字段 +> +> 请不要试图填写注入 `client_id` `grant_type` 之类的由 RFC 预先定义的字段,这些字段会被覆盖掉 +> +> 如果实在有需要,请重新开一个类并实现 IOAuthClient 接口 + +## OpenId Connect + +>[!IMPORTANT] +> +> IdentityModel 提供的实现为精简版,可能不是标准 OpenID 实现,但应该够用.....吧? + +### 初始化 + +>[!TIP] +> ### 设备代码流模式 +> +> 如果只需要设备代码流登录,请设置 OnlyDeviceAuthorize 为 true,这将跳过 RedirectUri 的检查,从而允许传入空值 + +``` +var options = new OpenIdOptions{ + OpenIdDiscoveryAddress = "https://openid.example.com/.well-known/openid-configuration", + ClientId = "0712", + GetClient = () => _client +}; +var client = new OpenIdClient(options); + +client.InitializeAsync(CancellationToken.None) +``` + +>[!IMPORTANT] +> 因为需要从互联网拉取配置,基于 OpenID 协议(包括 OpenID)实现的客户端均需要在开始使用前调用 `.InitializeAsync()` + +>[!TIP] +> +> ### PKCE 支持 +> +> OpenID Client 原生支持(并默认启用) PKCE 扩展 +> +> 如果 PKCE 扩展支持导致登录问题,请设置 EnablePkceSupport 为 false + +OpenID Client 的登录过程与 OAuth 相同,请参考 SimpleOAuthClient 的用例 + +## Yggdrasil Connect + +Yggdrasil Connect Client 的初始化方式与 OpenID Client 相同,请直接参考 OpenID Client 的初始化方式 + +## Yggdrasli Legacy Login + +### 初始化 + +``` +``` + * + */ \ 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 index 029797c01..c072581a8 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs @@ -37,15 +37,14 @@ public async Task InitializeAsync(CancellationToken token) /// 获取授权端点地址 /// /// - /// /// /// /// /// 未调用 - public string GetAuthorizeUrl(string[] scopes, string redirectUri, string state, Dictionary? extData) + public string GetAuthorizeUrl(string[] scopes, string state, Dictionary? extData) { if (_client is null) throw new InvalidOperationException(); - return _client.GetAuthorizeUrl(scopes, redirectUri, state, extData); + return _client.GetAuthorizeUrl(scopes, state, extData); } /// /// 使用授权代码兑换令牌 diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs index a3242e302..90dac453a 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs @@ -13,10 +13,6 @@ 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) - { - - } // 重写这个鬼方法是因为 Yggdrasil Connect 有要求( diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs index 8c5c0c480..b9a9a83de 100644 --- a/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs @@ -18,17 +18,16 @@ public sealed class SimpleOAuthClient(OAuthClientOptions options):IOAuthClient /// 获取授权 Url /// /// 访问权限列表 - /// 重定向 Url /// /// /// - public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state,Dictionary? extData = null) + public string GetAuthorizeUrl(string[] scopes,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($"&redirect_uri={Uri.EscapeDataString(options.RedirectUri)}"); sb.Append($"&client_id={options.ClientId}&state={state}"); if (extData is null) return sb.ToString(); foreach (var kvp in extData) diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/IOAuthClient.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/IOAuthClient.cs index 2c0a04b05..dd035367a 100644 --- a/PCL.Core/Minecraft/IdentityModel/OAuth/IOAuthClient.cs +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/IOAuthClient.cs @@ -6,7 +6,7 @@ namespace PCL.Core.Minecraft.IdentityModel.OAuth; public interface IOAuthClient { - public string GetAuthorizeUrl(string[] scopes,string redirectUri,string state,Dictionary? extData); + public string GetAuthorizeUrl(string[] scopes,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); diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs index 85c6bd82f..13c6a0654 100644 --- a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Net; using System.Net.Http; using System.Text; @@ -35,12 +33,12 @@ public sealed class YggdrasilLegacyClient(YggdrasilLegacyAuthenticateOptions opt }; var address = $"{options.YggdrasilApiLocation}/authserver/authenticate"; using var request = new HttpRequestMessage(HttpMethod.Post, address); - if(options.RequestHeaders is not null) - foreach (var kvp in options.RequestHeaders) + if(options.Headers is not null) + foreach (var kvp in options.Headers) _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); using var content = - new StringContent(JsonSerializer.Serialize(options), Encoding.UTF8, "application/json"); + new StringContent(JsonSerializer.Serialize(credential), Encoding.UTF8, "application/json"); request.Content = content; using var response = await options.GetClient.Invoke().SendAsync(request,token); return @@ -65,8 +63,8 @@ public sealed class YggdrasilLegacyClient(YggdrasilLegacyAuthenticateOptions opt var address = $"{options.YggdrasilApiLocation}/authserver/refresh"; using var request = new HttpRequestMessage(HttpMethod.Post, address); - if(options.RequestHeaders is not null) - foreach (var kvp in options.RequestHeaders) + if(options.Headers is not null) + foreach (var kvp in options.Headers) _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); using var content = new StringContent( JsonSerializer.Serialize(refreshData), Encoding.UTF8, "application/json"); @@ -90,8 +88,8 @@ public async Task ValidateAsync(CancellationToken token) var address = $"{options.YggdrasilApiLocation}/authserver/invalidate"; using var request = new HttpRequestMessage(HttpMethod.Post, address); - if(options.RequestHeaders is not null) - foreach (var kvp in options.RequestHeaders) + if(options.Headers is not null) + foreach (var kvp in options.Headers) _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); using var content = new StringContent( @@ -116,8 +114,8 @@ public async Task InvalidateAsync(CancellationToken token) var address = $"{options.YggdrasilApiLocation}/authserver/invalidate"; using var request = new HttpRequestMessage(HttpMethod.Post, address); - if(options.RequestHeaders is not null) - foreach (var kvp in options.RequestHeaders) + if(options.Headers is not null) + foreach (var kvp in options.Headers) _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); using var content = new StringContent( @@ -142,8 +140,8 @@ public async Task InvalidateAsync(CancellationToken token) var address = $"{options.YggdrasilApiLocation}/authserver/signout"; using var request = new HttpRequestMessage(HttpMethod.Post, address); - if(options.RequestHeaders is not null) - foreach (var kvp in options.RequestHeaders) + if(options.Headers is not null) + foreach (var kvp in options.Headers) _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); using var content = new StringContent(signoutData, Encoding.UTF8, "application/json"); request.Content = content; diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Options.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Options.cs index 592dfd784..90581a8c9 100644 --- a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Options.cs +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Options.cs @@ -26,5 +26,5 @@ public record YggdrasilLegacyAuthenticateOptions /// /// 请求头 /// - public Dictionary? RequestHeaders { get; set; } + public Dictionary? Headers { get; set; } } diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs index 2668be6ab..fe580ac96 100644 --- a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs @@ -16,7 +16,7 @@ public record Profile /// /// 属性信息 /// - [JsonPropertyName("properties")] public PlayerProperty[] Properties { get; init; } + [JsonPropertyName("properties")] public PlayerProperty[]? Properties { get; init; } } public record PlayerProperty From 9586a8e77023514eb6b7a19543a743804ce711ee 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, 24 Feb 2026 01:49:34 +0800 Subject: [PATCH 21/24] =?UTF-8?q?=E9=98=B3=E5=85=89=E5=80=BE=E6=B4=92=20?= =?UTF-8?q?=E5=83=8F=E5=B8=8C=E6=9C=9B=E5=B0=B1=E5=9C=A8=E8=84=9A=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/Pkce/PkceClient.cs | 150 ------------------ 1 file changed, 150 deletions(-) diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs index 1a836bbd3..4ef2e8d38 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs @@ -86,153 +86,3 @@ public string GetAuthorizeUrl(string[] scopes, string state, Dictionary[!IMPORTANT] -> ## IdentityModel 不会尝试处理任何错误 -> -> -> 因为其设计目标是作为协议传输层将调用方提供的数据转换为标准数据格式(类似 HttpClient) -> 错误需要调用方自行处理 - -## 普通 OAuth - -### 初始化 - -```csharp -var option = new OAuthClientOptions() -{ - ClientId = "0712", - GetClient = () => _client, - Headers = new(){ - ["User-Agent"] = "PCL-CE/2.14.2" - }, - Meta = new EndpointMeta - { - AuthorizeEndpoint = "https://open.example.com/oauth/v2.0/authorize", - DeviceEndpoint = "https://open.example.com/oauth/v2.0/device", - TokenEndpoint = "https://open.example.com/oauth/v2.0/token" - }, - RedirectUri = "http://localhost:7120/oauth/callback" -}; - -var client = new SimpleOAuthClient(option); -``` - ->[!Tip] -> -> ### PKCE 扩展 -> -> 如果需要 PKCE 扩展支持,,请使用 PkceClient 而不是 SimpleOAuthClient - -### 授权代码流 - -获取授权 Url - -```csharp -var authorizeUri = client.GetAuthorizeUrl(["offline_access"],"20120712"); -``` -使用授权代码兑换令牌 - -```csharp -var result = await client.AuthorizeWithCodeAsync("",CancellationToken.None); -``` - -### 设备代码流 - -获取代码对 - -```csharp -var data = await client.GetCodePairAsync(["offline_access"], CancellationToken.None); -``` - -```csharp -var data = await client.AuthorizeWithDeviceAsync(data, CancellationToken.None); -``` - ->[!IMPORTANT] -> ### AuthorizeWithDeviceAsync 仅会发送一次请求(不会轮询) -> -> 你可以配合 Polly 做重试,或者自己糊也行,但绝对不能只调用一次 - -### 刷新登录 - -```csharp -await client.AuthorizeWithSilentAsync(data, CancellationToken.None); -``` - ->[!TIP] -> ### 扩展数据支持 -> -> 如果某一个协议基于 OAuth 但需要提供更多的请求载荷,你可以设置每个方法的 extData 参数(字典)并提供对应的数据 - ->[!WARNING] -> -> ### 不要填写预定义字段 -> -> 请不要试图填写注入 `client_id` `grant_type` 之类的由 RFC 预先定义的字段,这些字段会被覆盖掉 -> -> 如果实在有需要,请重新开一个类并实现 IOAuthClient 接口 - -## OpenId Connect - ->[!IMPORTANT] -> -> IdentityModel 提供的实现为精简版,可能不是标准 OpenID 实现,但应该够用.....吧? - -### 初始化 - ->[!TIP] -> ### 设备代码流模式 -> -> 如果只需要设备代码流登录,请设置 OnlyDeviceAuthorize 为 true,这将跳过 RedirectUri 的检查,从而允许传入空值 - -``` -var options = new OpenIdOptions{ - OpenIdDiscoveryAddress = "https://openid.example.com/.well-known/openid-configuration", - ClientId = "0712", - GetClient = () => _client -}; -var client = new OpenIdClient(options); - -client.InitializeAsync(CancellationToken.None) -``` - ->[!IMPORTANT] -> 因为需要从互联网拉取配置,基于 OpenID 协议(包括 OpenID)实现的客户端均需要在开始使用前调用 `.InitializeAsync()` - ->[!TIP] -> -> ### PKCE 支持 -> -> OpenID Client 原生支持(并默认启用) PKCE 扩展 -> -> 如果 PKCE 扩展支持导致登录问题,请设置 EnablePkceSupport 为 false - -OpenID Client 的登录过程与 OAuth 相同,请参考 SimpleOAuthClient 的用例 - -## Yggdrasil Connect - -Yggdrasil Connect Client 的初始化方式与 OpenID Client 相同,请直接参考 OpenID Client 的初始化方式 - -## Yggdrasli Legacy Login - -### 初始化 - -``` -``` - * - */ \ No newline at end of file From b66d3ae8ed80f975087a7085dff03b9c381ec5af 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, 24 Feb 2026 02:01:19 +0800 Subject: [PATCH 22/24] =?UTF-8?q?I=20wll=20be=20paying=20=E7=83=A6?= =?UTF-8?q?=E6=81=BC=E9=83=BD=E5=BD=92=E4=BA=8E=E8=92=B8=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/JsonWebToken/JsonWebToken.cs | 270 ++++++++++++++++-- 1 file changed, 239 insertions(+), 31 deletions(-) diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs index 6b6862736..dcb63fb15 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs @@ -1,8 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Security; -using System.Threading.Tasks; +using System.Text.Json; using Microsoft.IdentityModel.Tokens; using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; @@ -11,54 +11,262 @@ namespace PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken; /// /// Json Web Token 类 /// -/// -/// -public class JsonWebToken(string token,OpenIdMetadata meta) +/// JWT 令牌字符串 +/// OpenID 元数据 +public class JsonWebToken(string token, OpenIdMetadata meta) { - public delegate SecurityToken? TokenValidateCallback(OpenIdMetadata metadata,string token, JsonWebKey key,string clientId); + public delegate SecurityToken? TokenValidateCallback(OpenIdMetadata metadata, string token, JsonWebKey? key, string? clientId); - public TokenValidateCallback SecurityTokenValidateCallback { get; set; } = static (meta,token, key,clientId) => + /// + /// 安全令牌验证回调函数,默认验证签名、发行者、nbf 和 exp + /// + public TokenValidateCallback SecurityTokenValidateCallback { get; set; } = static (meta, token, key, clientId) => { - var handler = new JwtSecurityTokenHandler(); - var parameter = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = meta.Issuer, - ValidateAudience = true, - ValidAudience = clientId, - }; - handler.ValidateToken(token, parameter, out var secToken); - return secToken; + try + { + var handler = new JwtSecurityTokenHandler(); + + var parameter = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = meta.Issuer, + ValidateAudience = !string.IsNullOrEmpty(clientId), + ValidAudience = clientId, + ValidateIssuerSigningKey = key != null, + IssuerSigningKey = key != null ? new JsonWebKeySet { Keys = { key } }.Keys[0] : null, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(60) + }; + + handler.ValidateToken(token, parameter, out var secToken); + return secToken; + } + catch (Exception ex) + { + throw new SecurityException($"令牌验证失败:{ex.Message}", ex); + } }; - + private bool _verified; + private JwtSecurityToken? _parsedToken; + private readonly JwtSecurityTokenHandler _tokenHandler = new(); + + /// + /// 解析令牌(不验证签名) + /// + /// 解析后的 JWT 令牌对象 + /// 令牌格式无效 + private JwtSecurityToken _ParseToken() + { + if (_parsedToken != null) + return _parsedToken; + + try + { + if (!_tokenHandler.CanReadToken(token)) + throw new SecurityException("无法读取令牌:格式无效"); + + _parsedToken = _tokenHandler.ReadJwtToken(token); + return _parsedToken; + } + catch (Exception ex) + { + throw new SecurityException($"令牌解析失败:{ex.Message}", ex); + } + } + /// /// 尝试读取 Token 中的字段 /// /// 是否允许在未验证的情况下读取字段,若为 false,当 Token 未验证时将抛出异常 - /// - /// - /// 未调用 - public T ReadTokenPayload(bool allowUnverifyToken = false) + /// 声明值的目标类型 + /// 解析后的声明对象 + /// 未调用 VerifySignature() 且 allowUnverifyToken 为 false + /// 令牌中不存在 payload 数据 + public T? ReadTokenPayload(bool allowUnverifyToken = false) { - throw new NotImplementedException(); + if (!allowUnverifyToken && !_verified) + throw new SecurityException("不安全的令牌"); + + try + { + var jwtToken = _ParseToken(); + + if (jwtToken.Payload == null || jwtToken.Payload.Count == 0) + throw new InvalidOperationException("令牌 Payload 无效"); + + if (typeof(T).IsAssignableFrom(typeof(Dictionary))) + return (T)(object)jwtToken.Payload; + + if (typeof(T) == typeof(JwtPayload)) + return (T)(object)jwtToken.Payload; + + var payloadJson = JsonSerializer.Serialize(jwtToken.Payload); + var result = JsonSerializer.Deserialize(payloadJson); + + return result; + } + catch (SecurityException) + { + throw; + } + catch (Exception ex) + { + throw new SecurityException($"读取令牌 payload 失败:{ex.Message}", ex); + } } + /// /// 读取 Token 头 /// - /// - /// - public T ReadTokenHeader() + /// 声明值的目标类型 + /// 解析后的头对象 + /// 令牌中不存在 header 数据 + public T? ReadTokenHeader() { - throw new NotImplementedException(); + try + { + var jwtToken = _ParseToken(); + + if (jwtToken.Header == null || jwtToken.Header.Count == 0) + throw new InvalidOperationException("令牌中不存在 header 数据"); + + if (typeof(T).IsAssignableFrom(typeof(Dictionary))) + return (T)(object)jwtToken.Header; + + if (typeof(T) == typeof(JwtHeader)) + return (T)(object)jwtToken.Header; + + var headerJson = JsonSerializer.Serialize(jwtToken.Header); + var result = JsonSerializer.Deserialize(headerJson); + + return result; + } + catch (Exception ex) + { + throw new SecurityException($"读取令牌 header 失败:{ex.Message}", ex); + } } + /// /// 对 Token 进行签名验证
/// 默认情况下仅对签名、iss、nbf、exp 进行验证,如果需要更细粒度验证,请设置 ///
- /// - public SecurityToken? TryVerifySignature(JsonWebKey key,string clientId) + /// 用于验证签名的 JSON Web Key + /// 预期的受众(audience),可选 + /// 验证成功返回 SecurityToken 对象,否则返回 null + public SecurityToken? VerifySignature(JsonWebKey key, string? clientId = null) { - return SecurityTokenValidateCallback.Invoke(meta, token, key, clientId); + try + { + var result = SecurityTokenValidateCallback.Invoke(meta, token, key, clientId); + if (result != null) + _verified = true; + return result; + } + catch (Exception ex) + { + throw new SecurityException($"令牌签名验证失败:{ex.Message}", ex); + } } -} \ No newline at end of file + + /// + /// 重载方法,用于无参调用验证(仅验证基本声明) + /// + /// 验证成功返回 SecurityToken 对象,否则返回 null + public SecurityToken? VerifySignature() + { + try + { + var result = SecurityTokenValidateCallback.Invoke(meta, token, null, null); + if (result != null) + _verified = true; + return result; + } + catch (Exception ex) + { + throw new SecurityException($"令牌验证失败:{ex.Message}", ex); + } + } + + /// + /// 获取令牌的过期时间 + /// + /// 过期时间,若不存在则返回 null + public DateTime? GetExpirationTime() + { + try + { + var jwtToken = _ParseToken(); + return jwtToken.ValidTo != DateTime.MinValue ? jwtToken.ValidTo : null; + } + catch (Exception ex) + { + throw new SecurityException($"获取令牌过期时间失败:{ex.Message}", ex); + } + } + + /// + /// 获取令牌的签发时间 + /// + /// 签发时间,若不存在则返回 null + public DateTime? GetIssuedAtTime() + { + try + { + var jwtToken = _ParseToken(); + return jwtToken.ValidFrom != DateTime.MinValue ? jwtToken.ValidFrom : null; + } + catch (Exception ex) + { + throw new SecurityException($"获取令牌签发时间失败:{ex.Message}", ex); + } + } + + /// + /// 检查令牌是否已过期 + /// + /// 若已过期返回 true,否则返回 false + public bool IsExpired() + { + try + { + var expTime = GetExpirationTime(); + return expTime.HasValue && DateTime.UtcNow > expTime.Value; + } + catch + { + return true; // 如果无法解析,视为已过期 + } + } + + /// + /// 获取特定声明的值 + /// + /// 声明类型 + /// 是否允许在未验证的情况下读取 + /// 声明值,若不存在则返回 null + public string? GetClaimValue(string claimType, bool allowUnverifyToken = false) + { + try + { + var payload = ReadTokenPayload>(allowUnverifyToken); + return payload?.TryGetValue(claimType, out var value) ?? false ? value.ToString() : null; + } + catch (Exception ex) + { + throw new SecurityException($"获取声明值失败({claimType}):{ex.Message}", ex); + } + } + + /// + /// 获取原始令牌字符串 + /// + /// JWT 令牌字符串 + public string GetTokenString() => token; + + /// + /// 检查令牌验证状态 + /// + public bool IsVerified => _verified; +} From 7c6ec7731c7e0232b7ed88b711ffb80ed2e77784 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, 24 Feb 2026 02:03:33 +0800 Subject: [PATCH 23/24] =?UTF-8?q?=E8=A2=AB=E9=9B=A8=E6=B0=B4=E6=B5=87?= =?UTF-8?q?=E7=81=8C=E7=9A=84=E8=8A=B1=20=E6=89=8D=E4=BC=9A=E6=9B=B4?= =?UTF-8?q?=E9=B2=9C=E8=89=B3=E6=9B=B4=E6=97=A0=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IdentityModel/Extensions/OpenId/OpenIdOptions.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs index 5eb623e3f..e9b5f0b74 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs @@ -47,7 +47,7 @@ public required string ClientId /// /// 获取 HttpClient,生命周期由调用方管理 /// - public Func GetClient { get; set; } + public required Func GetClient { get; set; } /// /// OpenId 元数据,请勿自行设置此属性,而是应该调用 /// @@ -116,4 +116,5 @@ public virtual async Task BuildOAuthOptionsAsync(Cancellatio } }; } -} \ No newline at end of file + +} From ad78cbcfcd576578d0eeb67aea8077c63f60a5d4 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, 24 Feb 2026 02:07:59 +0800 Subject: [PATCH 24/24] =?UTF-8?q?=E5=B0=B1=E5=83=8F=E8=88=9E=E5=8F=B0?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E4=BD=A0=E5=95=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs index fcbf57090..d03539a08 100644 --- a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs @@ -94,4 +94,4 @@ public string GetAuthorizeUrl(string[] scopes, string state,Dictionary