MicrosoftLogin.cs 5.6 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. using System.Net.Http.Headers;
  2. using System.Text;
  3. using System.Text.Json.Nodes;
  4. namespace PCL.Communist.Core;
  5. public sealed record DeviceCodePrompt(string Code, Uri VerificationUri, string Message);
  6. public sealed class MicrosoftLogin(HttpClient http)
  7. {
  8. public static string ClientId => Environment.GetEnvironmentVariable("PCL_MS_CLIENT_ID") ?? "fe72edc2-3a6f-4280-90e8-e2beb64ce7e1";
  9. // Tokens live only in this session. A mobile secure-store implementation is required before persisting refresh tokens.
  10. public async Task<AccountSession> LoginAsync(Action<DeviceCodePrompt> prompt, CancellationToken cancellationToken = default)
  11. {
  12. var device = await FormAsync("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode",
  13. new() { ["client_id"] = ClientId, ["scope"] = "XboxLive.signin offline_access" }, cancellationToken);
  14. prompt(new(device["user_code"]!.GetValue<string>(), new Uri(device["verification_uri"]!.GetValue<string>()), device["message"]?.GetValue<string>() ?? "请在浏览器完成登录。"));
  15. var interval = Math.Max(5, device["interval"]?.GetValue<int>() ?? 5);
  16. var expires = DateTimeOffset.UtcNow.AddSeconds(device["expires_in"]!.GetValue<int>());
  17. JsonObject? oauth = null;
  18. while (DateTimeOffset.UtcNow < expires)
  19. {
  20. await Task.Delay(TimeSpan.FromSeconds(interval), cancellationToken);
  21. var result = await FormAsync("https://login.microsoftonline.com/consumers/oauth2/v2.0/token", new()
  22. {
  23. ["client_id"] = ClientId, ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
  24. ["device_code"] = device["device_code"]!.GetValue<string>()
  25. }, cancellationToken, allowOAuthError: true);
  26. if (result["access_token"] is not null) { oauth = result; break; }
  27. switch (result["error"]?.GetValue<string>())
  28. {
  29. case "authorization_pending": continue;
  30. case "slow_down": interval += 5; continue;
  31. case "authorization_declined": throw new InvalidOperationException("你取消了微软登录。");
  32. case "expired_token": throw new InvalidOperationException("登录代码已过期,请重新登录。");
  33. default: throw new InvalidOperationException("微软登录失败:" + result["error"]?.GetValue<string>());
  34. }
  35. }
  36. if (oauth is null) throw new InvalidOperationException("登录代码已过期,请重新登录。");
  37. var xbl = await JsonAsync("https://user.auth.xboxlive.com/user/authenticate", new JsonObject
  38. {
  39. ["Properties"] = new JsonObject { ["AuthMethod"] = "RPS", ["SiteName"] = "user.auth.xboxlive.com", ["RpsTicket"] = "d=" + oauth["access_token"]!.GetValue<string>() },
  40. ["RelyingParty"] = "http://auth.xboxlive.com", ["TokenType"] = "JWT"
  41. }, cancellationToken);
  42. var xsts = await JsonAsync("https://xsts.auth.xboxlive.com/xsts/authorize", new JsonObject
  43. {
  44. ["Properties"] = new JsonObject { ["SandboxId"] = "RETAIL", ["UserTokens"] = new JsonArray(xbl["Token"]!.GetValue<string>()) },
  45. ["RelyingParty"] = "rp://api.minecraftservices.com/", ["TokenType"] = "JWT"
  46. }, cancellationToken);
  47. var mc = await JsonAsync("https://api.minecraftservices.com/authentication/login_with_xbox", new JsonObject
  48. {
  49. ["identityToken"] = $"XBL3.0 x={xsts["DisplayClaims"]!["xui"]![0]!["uhs"]!.GetValue<string>()};{xsts["Token"]!.GetValue<string>()}"
  50. }, cancellationToken);
  51. var token = mc["access_token"]!.GetValue<string>();
  52. var entitlements = await AuthenticatedGetAsync("https://api.minecraftservices.com/entitlements/mcstore", token, cancellationToken);
  53. if (entitlements["items"] is not JsonArray items || items.Count == 0) throw new InvalidOperationException("此账号尚未购买 Minecraft Java,或 Game Pass 已到期。");
  54. var profile = await AuthenticatedGetAsync("https://api.minecraftservices.com/minecraft/profile", token, cancellationToken);
  55. return new(profile["name"]!.GetValue<string>(), profile["id"]!.GetValue<string>(), token, "msa",
  56. DateTimeOffset.UtcNow.AddSeconds(mc["expires_in"]!.GetValue<int>() - 60));
  57. }
  58. private async Task<JsonObject> FormAsync(string url, Dictionary<string, string> fields, CancellationToken token, bool allowOAuthError = false)
  59. {
  60. using var response = await http.PostAsync(url, new FormUrlEncodedContent(fields), token);
  61. if (!allowOAuthError) response.EnsureSuccessStatusCode();
  62. return JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!.AsObject();
  63. }
  64. private async Task<JsonObject> JsonAsync(string url, JsonObject body, CancellationToken token)
  65. {
  66. using var response = await http.PostAsync(url, new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), token);
  67. if (!response.IsSuccessStatusCode) throw new HttpRequestException($"账号服务返回 {(int)response.StatusCode},请检查 Xbox 账号设置后重试。");
  68. return JsonNode.Parse(await response.Content.ReadAsStringAsync(token))!.AsObject();
  69. }
  70. private async Task<JsonObject> AuthenticatedGetAsync(string url, string token, CancellationToken cancellationToken)
  71. {
  72. using var request = new HttpRequestMessage(HttpMethod.Get, url);
  73. request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
  74. using var response = await http.SendAsync(request, cancellationToken);
  75. response.EnsureSuccessStatusCode();
  76. return JsonNode.Parse(await response.Content.ReadAsStringAsync(cancellationToken))!.AsObject();
  77. }
  78. }