feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -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 { get; init; }
}
@@ -0,0 +1,273 @@
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;
using PCL.Core.Utils;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken;
/// <summary>
/// Json Web Token 类
/// </summary>
/// <param name="token">JWT 令牌字符串</param>
/// <param name="meta">OpenID 元数据</param>
public class JsonWebToken(string token, OpenIdMetadata meta)
{
public delegate SecurityToken? TokenValidateCallback(OpenIdMetadata metadata, string token, JsonWebKey? key, string? clientId);
/// <summary>
/// 安全令牌验证回调函数,默认验证签名、发行者、nbf 和 exp
/// </summary>
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 is not null,
IssuerSigningKey = key is not 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();
/// <summary>
/// 解析令牌(不验证签名)
/// </summary>
/// <returns>解析后的 JWT 令牌对象</returns>
/// <exception cref="SecurityException">令牌格式无效</exception>
private JwtSecurityToken _ParseToken()
{
if (_parsedToken is not 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);
}
}
/// <summary>
/// 尝试读取 Token 中的字段
/// </summary>
/// <param name="allowUnverifyToken">是否允许在未验证的情况下读取字段,若为 false,当 Token 未验证时将抛出异常</param>
/// <typeparam name="T">声明值的目标类型</typeparam>
/// <returns>解析后的声明对象</returns>
/// <exception cref="SecurityException">未调用 VerifySignature() 且 allowUnverifyToken 为 false</exception>
/// <exception cref="InvalidOperationException">令牌中不存在 payload 数据</exception>
public T? ReadTokenPayload<T>(bool allowUnverifyToken = false)
{
if (!allowUnverifyToken && !_verified)
throw new SecurityException("不安全的令牌");
try
{
var jwtToken = _ParseToken();
if (jwtToken.Payload is null || jwtToken.Payload.Count == 0)
throw new InvalidOperationException("令牌 Payload 无效");
if (typeof(T).IsAssignableFrom(typeof(Dictionary<string, object>)))
return (T)(object)jwtToken.Payload;
if (typeof(T) == typeof(JwtPayload))
return (T)(object)jwtToken.Payload;
var payloadJson = JsonSerializer.Serialize(jwtToken.Payload, JsonCompat.SerializerOptions);
var result = JsonSerializer.Deserialize<T>(payloadJson, JsonCompat.SerializerOptions);
return result;
}
catch (SecurityException)
{
throw;
}
catch (Exception ex)
{
throw new SecurityException($"读取令牌 payload 失败:{ex.Message}", ex);
}
}
/// <summary>
/// 读取 Token 头
/// </summary>
/// <typeparam name="T">声明值的目标类型</typeparam>
/// <returns>解析后的头对象</returns>
/// <exception cref="InvalidOperationException">令牌中不存在 header 数据</exception>
public T? ReadTokenHeader<T>()
{
try
{
var jwtToken = _ParseToken();
if (jwtToken.Header is null || jwtToken.Header.Count == 0)
throw new InvalidOperationException("令牌中不存在 header 数据");
if (typeof(T).IsAssignableFrom(typeof(Dictionary<string, object>)))
return (T)(object)jwtToken.Header;
if (typeof(T) == typeof(JwtHeader))
return (T)(object)jwtToken.Header;
var headerJson = JsonSerializer.Serialize(jwtToken.Header, JsonCompat.SerializerOptions);
var result = JsonSerializer.Deserialize<T>(headerJson, JsonCompat.SerializerOptions);
return result;
}
catch (Exception ex)
{
throw new SecurityException($"读取令牌 header 失败:{ex.Message}", ex);
}
}
/// <summary>
/// 对 Token 进行签名验证 <br/>
/// 默认情况下仅对签名、iss、nbf、exp 进行验证,如果需要更细粒度验证,请设置 <see cref="SecurityTokenValidateCallback"/>
/// </summary>
/// <param name="key">用于验证签名的 JSON Web Key</param>
/// <param name="clientId">预期的受众(audience),可选</param>
/// <returns>验证成功返回 SecurityToken 对象,否则返回 null</returns>
public SecurityToken? VerifySignature(JsonWebKey key, string? clientId = null)
{
try
{
var result = SecurityTokenValidateCallback.Invoke(meta, token, key, clientId);
if (result is not null)
_verified = true;
return result;
}
catch (Exception ex)
{
throw new SecurityException($"令牌签名验证失败:{ex.Message}", ex);
}
}
/// <summary>
/// 重载方法,用于无参调用验证(仅验证基本声明)
/// </summary>
/// <returns>验证成功返回 SecurityToken 对象,否则返回 null</returns>
public SecurityToken? VerifySignature()
{
try
{
var result = SecurityTokenValidateCallback.Invoke(meta, token, null, null);
if (result is not null)
_verified = true;
return result;
}
catch (Exception ex)
{
throw new SecurityException($"令牌验证失败:{ex.Message}", ex);
}
}
/// <summary>
/// 获取令牌的过期时间
/// </summary>
/// <returns>过期时间,若不存在则返回 null</returns>
public DateTime? GetExpirationTime()
{
try
{
var jwtToken = _ParseToken();
return jwtToken.ValidTo != DateTime.MinValue ? jwtToken.ValidTo : null;
}
catch (Exception ex)
{
throw new SecurityException($"获取令牌过期时间失败:{ex.Message}", ex);
}
}
/// <summary>
/// 获取令牌的签发时间
/// </summary>
/// <returns>签发时间,若不存在则返回 null</returns>
public DateTime? GetIssuedAtTime()
{
try
{
var jwtToken = _ParseToken();
return jwtToken.ValidFrom != DateTime.MinValue ? jwtToken.ValidFrom : null;
}
catch (Exception ex)
{
throw new SecurityException($"获取令牌签发时间失败:{ex.Message}", ex);
}
}
/// <summary>
/// 检查令牌是否已过期
/// </summary>
/// <returns>若已过期返回 true,否则返回 false</returns>
public bool IsExpired()
{
try
{
var expTime = GetExpirationTime();
return expTime.HasValue && DateTime.UtcNow > expTime.Value;
}
catch
{
return true; // 如果无法解析,视为已过期
}
}
/// <summary>
/// 获取特定声明的值
/// </summary>
/// <param name="claimType">声明类型</param>
/// <param name="allowUnverifyToken">是否允许在未验证的情况下读取</param>
/// <returns>声明值,若不存在则返回 null</returns>
public string? GetClaimValue(string claimType, bool allowUnverifyToken = false)
{
try
{
var payload = ReadTokenPayload<Dictionary<string, object>>(allowUnverifyToken);
return payload?.TryGetValue(claimType, out var value) ?? false ? value.ToString() : null;
}
catch (Exception ex)
{
throw new SecurityException($"获取声明值失败({claimType}):{ex.Message}", ex);
}
}
/// <summary>
/// 获取原始令牌字符串
/// </summary>
/// <returns>JWT 令牌字符串</returns>
public string GetTokenString() => token;
/// <summary>
/// 检查令牌验证状态
/// </summary>
public bool IsVerified => _verified;
}
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Minecraft.IdentityModel;
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;
/// <summary>
/// 初始化并从网络加载 OpenId 配置
/// </summary>
/// <param name="token"></param>
/// <param name="checkAddress"></param>
/// <exception cref="IdentityModelConfigurationException">当要求检查地址并不存在任何授权端点时,将触发此错误</exception>
public async Task InitializeAsync(CancellationToken token,bool checkAddress = false)
{
await options.InitializeAsync(token);
var opt = options.BuildOAuthOptions();
if (checkAddress && opt.Meta.AuthorizeEndpoint.IsNullOrEmpty() && opt.Meta.DeviceEndpoint.IsNullOrEmpty())
throw new IdentityModelConfigurationException("OpenID 元数据缺少授权代码流端点和设备代码流端点");
_client = options.EnablePkceSupport ? new PkceClient(opt) : new SimpleOAuthClient(opt);
}
/// <summary>
/// 获取授权代码流地址
/// </summary>
/// <param name="scopes">权限列表</param>
/// <param name="state"></param>
/// <param name="extData">扩展数据</param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public string GetAuthorizeUrl(string[] scopes, string state,Dictionary<string,string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
return _client.GetAuthorizeUrl(scopes, state, extData);
}
/// <summary>
/// 使用授权代码兑换 Token
/// </summary>
/// <param name="code"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
return await _client.AuthorizeWithCodeAsync(code, token, extData);
}
/// <summary>
/// 获取设备代码流代码对
/// </summary>
/// <param name="scopes"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<DeviceCodeData?> GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
return await _client.GetCodePairAsync(scopes, token, extData);
}
/// <summary>
/// 发起一次验证,以检查认证是否成功
/// </summary>
/// <param name="data"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
return await _client.AuthorizeWithDeviceAsync(data, token, extData);
}
/// <summary>
/// 进行一次刷新调用
/// </summary>
/// <param name="data"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
return await _client.AuthorizeWithSilentAsync(data, token, extData);
}
}
@@ -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<string> ScopesSupported { get; init; }
[JsonPropertyName("subject_types_supported")]
public required IReadOnlyList<string> SubjectTypesSupported { get; init; }
[JsonPropertyName("id_token_signing_alg_values_supported")]
public required IReadOnlyList<string> IdTokenSigningAlgValuesSupported { get; init; }
}
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
using PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken;
using PCL.Core.Minecraft.IdentityModel.OAuth;
using PCL.Core.Minecraft.IdentityModel;
using PCL.Core.IO.Net.Http;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
public record OpenIdOptions
{
/// <summary>
/// OpenId Discovery 地址
/// </summary>
public required string OpenIdDiscoveryAddress { get; set; }
/// <summary>
/// 客户端 ID(必须设置)
/// </summary>
public required string ClientId
{
get;
set;
}
// 为了让 YggdrasilConnect Client 复用代码做的逻辑
/// <summary>
/// 是否只使用设备代码流授权
/// </summary>
public bool OnlyDeviceAuthorize { get; set; }
/// <summary>
/// 回调 Uri
/// </summary>
public string? RedirectUri { get; set; }
/// <summary>
/// 发送 HTTP 请求时设置的请求头,仅适用于请求头(丢到 HttpRequestMessage 不会报错的那种)
/// </summary>
public Dictionary<string, string>? Headers { get; set; }
/// <summary>
/// 是否启用 PKCE 支持,默认启用
/// </summary>
public bool EnablePkceSupport { get; set; } = true;
/// <summary>
/// 获取 HttpClient,生命周期由调用方管理
/// </summary>
public required Func<HttpClient> GetClient { get; set; }
/// <summary>
/// OpenId 元数据,请勿自行设置此属性,而是应该调用 <see cref="InitializeAsync"/>
/// </summary>
public OpenIdMetadata? Meta { get; internal set; }
/// <summary>
/// 从互联网拉取 OpenID 配置信息
/// </summary>
/// <param name="token"></param>
public virtual async Task InitializeAsync(CancellationToken token)
{
using var response = await HttpRequest
.Create(OpenIdDiscoveryAddress)
.WithHeaders(Headers ?? [])
.SendAsync(GetClient.Invoke(), cancellationToken: token)
.ConfigureAwait(false);
Meta = await response
.AsJsonAsync<OpenIdMetadata>(cancellationToken: token)
.ConfigureAwait(false);
}
/// <summary>
/// 获取 Json Web Key
/// </summary>
/// <param name="kid">密钥 ID</param>
/// <param name="token"></param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/>,或找不到匹配的 Jwk</exception>
public async Task<JsonWebKey> GetSignatureKeyAsync(string kid,CancellationToken token)
{
if (Meta?.JwksUri is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 加载 OpenID 元数据");
using var response = await HttpRequest.Create(Meta.JwksUri)
.WithHeaders(Headers ?? [])
.SendAsync(GetClient.Invoke(), cancellationToken: token)
.ConfigureAwait(false);
var result = await response
.AsJsonAsync<JsonWebKeys>(cancellationToken: token)
.ConfigureAwait(false);
return result?.Keys.SingleOrDefault(k => k.Kid == kid)
?? throw new IdentityModelConfigurationException($"找不到匹配的 Jwk{kid}");
}
/// <summary>
/// 构建 OAuth 客户端配置
/// </summary>
/// <returns><see cref="OAuthClientOptions">OAuth 客户端选项</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/> 或缺少授权所需配置</exception>
public virtual OAuthClientOptions BuildOAuthOptions()
{
if (Meta is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 加载 OpenID 元数据");
if (string.IsNullOrEmpty(Meta.TokenEndpoint))
throw new IdentityModelConfigurationException("OpenID 元数据缺少 TokenEndpoint");
if (!OnlyDeviceAuthorize && string.IsNullOrEmpty(RedirectUri))
throw new IdentityModelConfigurationException("授权代码流需要设置 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,
}
};
}
}
@@ -0,0 +1,7 @@
namespace PCL.Core.Minecraft.IdentityModel.Extensions.Pkce;
public enum PkceChallengeOptions
{
Sha256,
PlainText
}
@@ -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;
/// <summary>
/// 带 PKCE 支持的客户端 <br/>
/// 此客户端并非线程安全,请勿在多个线程间共享示例
/// </summary>
/// <param name="options"></param>
public class PkceClient(OAuthClientOptions options):IOAuthClient
{
private byte[] _ChallengeCode { get; set; } = new byte[32];
private bool _isCallGetAuthorizeUrl;
/// <summary>
/// 设置验证方法,支持 PlainText 和 SHA256
/// </summary>
public PkceChallengeOptions ChallengeMethod { get; private set; } = PkceChallengeOptions.Sha256;
private readonly SimpleOAuthClient _client = new(options);
/// <summary>
/// 获取授权地址
/// </summary>
/// <param name="scopes"></param>
/// <param name="state"></param>
/// <param name="extData"></param>
/// <returns></returns>
public string GetAuthorizeUrl(string[] scopes, string state, Dictionary<string, string>? 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);
}
/// <summary>
/// 使用授权代码兑换令牌
/// </summary>
/// <param name="code"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task<AuthorizeResult?> AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary<string, string>? 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);
}
/// <summary>
/// 获取代码对
/// </summary>
/// <param name="scopes"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
public async Task<DeviceCodeData?> GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary<string, string>? extData = null)
{
return await _client.GetCodePairAsync(scopes, token, extData);
}
/// <summary>
///
/// </summary>
/// <param name="data"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
public async Task<AuthorizeResult?> AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary<string, string>? extData = null)
{
return await _client.AuthorizeWithDeviceAsync(data, token, extData);
}
public async Task<AuthorizeResult?> AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary<string, string>? extData = null)
{
return await _client.AuthorizeWithSilentAsync(data, token, extData);
}
}
@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Minecraft.IdentityModel;
using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
using PCL.Core.Minecraft.IdentityModel.OAuth;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect;
// Steven Qiu 说这东西完全就是 OpenId + 魔改了一部分,所以可以直接复用 OpenId 的逻辑
/// <summary>
/// Yggdrasil Connect Client 客户端
/// </summary>
public class YggdrasilClient:IOAuthClient
{
private OpenIdClient? _client;
private YggdrasilOptions _options;
public YggdrasilClient(YggdrasilOptions options)
{
_options = options;
}
/// <summary>
/// 初始化并拉取网络配置
/// </summary>
/// <exception cref="IdentityModelConfigurationException">无法获取 ClientId 或 OpenID 元数据无效</exception>
/// <param name="token"></param>
public async Task InitializeAsync(CancellationToken token)
{
_client = new OpenIdClient(_options);
await _client.InitializeAsync(token, true);
}
/// <summary>
/// 获取授权端点地址
/// </summary>
/// <param name="scopes">Yggdrasil Connect 规范所规定的权限</param>
/// <param name="state">用于关联会话</param>
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
/// <returns>OAuth 授权地址</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public string GetAuthorizeUrl(string[] scopes, string state, Dictionary<string, string>? extData)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
return _client.GetAuthorizeUrl(scopes, state, extData);
}
/// <summary>
/// 使用授权代码兑换令牌
/// </summary>
/// <param name="code">授权代码</param>
/// <param name="token">取消令牌</param>
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
/// <returns><see cref="AuthorizeResult"> 授权结果</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
return await _client.AuthorizeWithCodeAsync(code, token, extData);
}
/// <summary>
/// 获取代码对
/// </summary>
/// <param name="scopes">Yggdrasil Connect 规范所规定的权限</param>
/// <param name="token">取消令牌</param>
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
/// <returns><see cref="DeviceCodeData"> 设备流数据</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<DeviceCodeData?> GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
return await _client.GetCodePairAsync(scopes, token, extData);
}
/// <summary>
/// 发起一次请求验证用户授权状态
/// </summary>
/// <param name="data"><see cref="DeviceCodeData"> 设备流数据</param>
/// <param name="token">取消令牌</param>
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
/// <returns><see cref="AuthorizeResult"> 授权结果</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
return await _client.AuthorizeWithDeviceAsync(data, token, extData);
}
/// <summary>
/// 刷新登录
/// </summary>
/// <param name="data"><see cref="AuthorizeResult"> 先前的授权结果</param>
/// <param name="token">取消令牌</param>
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
/// <returns><see cref="AuthorizeResult"> 授权结果</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
return await _client.AuthorizeWithSilentAsync(data, token, extData);
}
}
@@ -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; }
}
@@ -0,0 +1,61 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Minecraft.IdentityModel;
using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
using PCL.Core.Minecraft.IdentityModel.OAuth;
using PCL.Core.Utils.Exts;
using PCL.Core.IO.Net.Http;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect;
public record YggdrasilOptions:OpenIdOptions
{
private string[] _scopesRequired = ["openid", "Yggdrasil.PlayerProfiles.Select", "Yggdrasil.Server.Join"];
// 重写这个鬼方法是因为 Yggdrasil Connect 有要求(
/// <summary>
/// 拉取 Yggdrasil 配置
/// </summary>
/// <param name="token"></param>
/// <exception cref="IdentityModelConfigurationException">无法加载元数据或缺少必要 scope</exception>
public override async Task InitializeAsync(CancellationToken token)
{
using var response = await HttpRequest
.Create(OpenIdDiscoveryAddress)
.WithHeaders(Headers ?? [])
.SendAsync(GetClient.Invoke(), cancellationToken: token)
.ConfigureAwait(false);
Meta = (await response.AsJsonAsync<YggdrasilConnectMetaData>(cancellationToken: token).ConfigureAwait(false))
?? throw new IdentityModelConfigurationException("无法加载 Yggdrasil Connect 元数据");
var missingScopes = _scopesRequired.Except(Meta.ScopesSupported).ToArray();
if (missingScopes.Length > 0)
throw new IdentityModelConfigurationException($"Yggdrasil Connect 元数据缺少必要 scope{string.Join(", ", missingScopes)}");
}
/// <summary>
/// 构建 OAuth 客户端选项
/// </summary>
/// <returns><see cerf="OAuthClientOptions"> OAuth 客户端选项</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/> 或缺少必要的客户端配置</exception>
public override OAuthClientOptions BuildOAuthOptions()
{
if (Meta is YggdrasilConnectMetaData meta)
{
var options = base.BuildOAuthOptions();
if (!options.ClientId.IsNullOrEmpty()) return options;
if (!meta.SharedClientId.IsNullOrEmpty())
{
options.ClientId = meta.SharedClientId;
return options;
}
throw new IdentityModelConfigurationException("Yggdrasil Connect 需要设置 ClientId,或由元数据提供 sharedClientId");
}
throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 加载 Yggdrasil Connect 元数据");
}
}