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/JsonWebToken/JsonWebToken.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs
new file mode 100644
index 000000000..dcb63fb15
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs
@@ -0,0 +1,272 @@
+using System;
+using System.Collections.Generic;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security;
+using System.Text.Json;
+using Microsoft.IdentityModel.Tokens;
+using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
+
+namespace PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken;
+
+///
+/// Json Web Token 类
+///
+/// JWT 令牌字符串
+/// OpenID 元数据
+public class JsonWebToken(string token, OpenIdMetadata meta)
+{
+ public delegate SecurityToken? TokenValidateCallback(OpenIdMetadata metadata, string token, JsonWebKey? key, string? clientId);
+
+ ///
+ /// 安全令牌验证回调函数,默认验证签名、发行者、nbf 和 exp
+ ///
+ public TokenValidateCallback SecurityTokenValidateCallback { get; set; } = static (meta, token, key, clientId) =>
+ {
+ 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 未验证时将抛出异常
+ /// 声明值的目标类型
+ /// 解析后的声明对象
+ /// 未调用 VerifySignature() 且 allowUnverifyToken 为 false
+ /// 令牌中不存在 payload 数据
+ public T? ReadTokenPayload(bool allowUnverifyToken = false)
+ {
+ 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 头
+ ///
+ /// 声明值的目标类型
+ /// 解析后的头对象
+ /// 令牌中不存在 header 数据
+ public T? ReadTokenHeader()
+ {
+ 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 进行验证,如果需要更细粒度验证,请设置
+ ///
+ /// 用于验证签名的 JSON Web Key
+ /// 预期的受众(audience),可选
+ /// 验证成功返回 SecurityToken 对象,否则返回 null
+ public SecurityToken? VerifySignature(JsonWebKey key, string? clientId = null)
+ {
+ 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);
+ }
+ }
+
+ ///
+ /// 重载方法,用于无参调用验证(仅验证基本声明)
+ ///
+ /// 验证成功返回 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;
+}
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..d03539a08
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs
@@ -0,0 +1,97 @@
+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;
+ ///
+ /// 初始化并从网络加载 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())
+ {
+ _client = options.EnablePkceSupport ? new PkceClient(opt) : new SimpleOAuthClient(opt);
+ return;
+ }
+
+ throw new InvalidOperationException();
+ }
+ ///
+ /// 获取授权代码流地址
+ ///
+ /// 权限列表
+ ///
+ /// 扩展数据
+ ///
+ /// 未调用
+ public string GetAuthorizeUrl(string[] scopes, string state,Dictionary? extData = null)
+ {
+ if (_client is null) throw new InvalidOperationException();
+ return _client.GetAuthorizeUrl(scopes, 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();
+ return await _client.AuthorizeWithSilentAsync(data, token, extData);
+ }
+}
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..e9b5f0b74
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs
@@ -0,0 +1,120 @@
+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
+{
+ ///
+ /// OpenId Discovery 地址
+ ///
+ public required string OpenIdDiscoveryAddress { get; set; }
+ ///
+ /// 客户端 ID(必须设置)
+ ///
+ public required string ClientId
+ {
+ get;
+ set;
+ }
+
+ // 为了让 YggdrasilConnect Client 复用代码做的逻辑
+
+ ///
+ /// 是否只使用设备代码流授权
+ ///
+ public bool OnlyDeviceAuthorize { get; set; }
+ ///
+ /// 回调 Uri
+ ///
+ public string? RedirectUri { get; set; }
+ ///
+ /// 发送 HTTP 请求时设置的请求头,仅适用于请求头(丢到 HttpRequestMessage 不会报错的那种)
+ ///
+ public Dictionary? Headers { get; set; }
+ ///
+ /// 是否启用 PKCE 支持,默认启用
+ ///
+ public bool EnablePkceSupport { get; set; } = true;
+ ///
+ /// 获取 HttpClient,生命周期由调用方管理
+ ///
+ public required Func GetClient { get; set; }
+ ///
+ /// OpenId 元数据,请勿自行设置此属性,而是应该调用
+ ///
+ public OpenIdMetadata? Meta { get; internal set; }
+
+ ///
+ /// 从互联网拉取 OpenID 配置信息
+ ///
+ ///
+ public virtual async Task InitializeAsync(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);
+ }
+ ///
+ /// 获取 Json Web Key
+ ///
+ /// 密钥 ID
+ ///
+ ///
+ /// 未调用
+ /// 找不到 Jwk 或 Jwk 配置无效
+ 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();
+ }
+ ///
+ /// 构建 OAuth 客户端配置
+ ///
+ ///
+ ///
+ /// 未调用
+ public virtual async Task BuildOAuthOptionsAsync(CancellationToken token)
+ {
+ if (Meta is null) throw new InvalidOperationException();
+ 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,
+ }
+ };
+ }
+
+}
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..4ef2e8d38
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs
@@ -0,0 +1,88 @@
+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;
+ ///
+ /// 设置验证方法,支持 PlainText 和 SHA256
+ ///
+ public PkceChallengeOptions ChallengeMethod { get; private set; } = PkceChallengeOptions.Sha256;
+ private readonly SimpleOAuthClient _client = new(options);
+ ///
+ /// 获取授权地址
+ ///
+ ///
+ ///
+ ///
+ ///
+ public string GetAuthorizeUrl(string[] scopes, string state, Dictionary? extData)
+ {
+ RandomNumberGenerator.Fill(_ChallengeCode);
+ extData ??= [];
+ extData["code_challenge"] = ChallengeMethod == PkceChallengeOptions.Sha256
+ ? SHA256Provider.Instance.ComputeHash(_ChallengeCode).ToHexString()
+ : _ChallengeCode.FromBytesToB64UrlSafe();
+ extData["code_challenge_method"] = ChallengeMethod == PkceChallengeOptions.Sha256 ? "S256":"plain";
+ _isCallGetAuthorizeUrl = true;
+ return _client.GetAuthorizeUrl(scopes, 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);
+ }
+}
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..c072581a8
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs
@@ -0,0 +1,104 @@
+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 并重新实例化 Client
+ ///
+ public async Task InitializeAsync(CancellationToken token)
+ {
+ _client = new OpenIdClient(_options);
+ await _client.InitializeAsync(token,true);
+ }
+ ///
+ /// 获取授权端点地址
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// 未调用
+ public string GetAuthorizeUrl(string[] scopes, string state, Dictionary? extData)
+ {
+ if (_client is null) throw new InvalidOperationException();
+ return _client.GetAuthorizeUrl(scopes, 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..90dac453a
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs
@@ -0,0 +1,63 @@
+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"];
+
+ // 重写这个鬼方法是因为 Yggdrasil Connect 有要求(
+
+ ///
+ /// 拉取 Yggdrasil 配置
+ ///
+ ///
+ ///
+ public override async Task InitializeAsync(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();
+ }
+ ///
+ /// 构建 OAuth 客户端选项
+ ///
+ ///
+ ///
+ /// 未调用
+ ///
+ ///
+ public override async Task BuildOAuthOptionsAsync(CancellationToken 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();
+ }
+
+ 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
new file mode 100644
index 000000000..4b46d4508
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs
@@ -0,0 +1,40 @@
+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; }
+ ///
+ /// 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
new file mode 100644
index 000000000..b9a9a83de
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs
@@ -0,0 +1,144 @@
+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
+ ///
+ /// 访问权限列表
+ ///
+ ///
+ ///
+ 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(options.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();
+ }
+
+ ///
+ /// 使用授权代码获取令牌
+ ///
+ /// 授权代码
+ /// 附加属性,不应该包含必须参数和预定义字段 (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
diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs
new file mode 100644
index 000000000..24835ee20
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs
@@ -0,0 +1,26 @@
+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; }
+ ///
+ /// 重定向 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
new file mode 100644
index 000000000..0dd8b2f22
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/OAuth/DeviceCodeData.cs
@@ -0,0 +1,49 @@
+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; }
+ ///
+ /// 验证 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
new file mode 100644
index 000000000..c26c9ec8a
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs
@@ -0,0 +1,17 @@
+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..dd035367a
--- /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 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
diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs
new file mode 100644
index 000000000..06ed1a007
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs
@@ -0,0 +1,17 @@
+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;
+
+///
+/// Yggdrasil Agent
+///
+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..13c6a0654
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs
@@ -0,0 +1,152 @@
+using System;
+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;
+
+///
+/// 提供 Yggdrasil 传统认证支持
+///
+/// 认证参数
+public sealed class YggdrasilLegacyClient(YggdrasilLegacyAuthenticateOptions options)
+{
+ ///
+ /// 异步向服务器发送一次登录请求
+ ///
+ ///
+ /// 认证结果
+ /// 用户名或密码无效
+ 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.Headers is not null)
+ foreach (var kvp in options.Headers)
+ _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value);
+
+ using var content =
+ new StringContent(JsonSerializer.Serialize(credential), 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.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");
+ 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);
+
+ var validateData = new YggdrasilRefresh()
+ {
+ AccessToken = options.AccessToken
+ };
+ var address = $"{options.YggdrasilApiLocation}/authserver/invalidate";
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, address);
+ 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(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.Headers is not null)
+ foreach (var kvp in options.Headers)
+ _ = 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.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;
+ 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..90581a8c9
--- /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? Headers { get; set; }
+}
diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs
new file mode 100644
index 000000000..fe580ac96
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs
@@ -0,0 +1,88 @@
+using System.Text.Json.Serialization;
+
+namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil;
+
+
+public record Profile
+{
+ ///
+ /// UUID
+ ///
+ [JsonPropertyName("id")] public required string Id { 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
new file mode 100644
index 000000000..415cab4a1
--- /dev/null
+++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs
@@ -0,0 +1,50 @@
+using System.Text.Json.Serialization;
+using PCL.Core.Link.Scaffolding.Client.Models;
+
+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 Agent Agent = new();
+ [JsonPropertyName("requestUser")] public bool RequestUser { get; set; }
+}
+
+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("selectedProfile")] public Profile? SelectedProfile { get; set; }
+}
\ No newline at end of file
diff --git a/PCL.Core/PCL.Core.csproj b/PCL.Core/PCL.Core.csproj
index 32fe6e52c..b8afc18c3 100644
--- a/PCL.Core/PCL.Core.csproj
+++ b/PCL.Core/PCL.Core.csproj
@@ -1,95 +1,97 @@
-
-
-
- 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)
- $(NoWarn);CS9113
-
-
-
- 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)
+ $(NoWarn);CS9113
+
+
+
+ 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