From 5147fb42df391d229e6630582a809d8a9541a36a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 15:38:47 +0000
Subject: [PATCH] Replace PPFT/urlPost HTML scraping with OAuth 2.0 device code
flow for 2FA support
- Add Microsoft.RequestDeviceCode() and Microsoft.PollDeviceCodeToken() methods
- Remove XboxLive.PreAuth() and XboxLive.UserLogin() (HTML scraping)
- Remove PreAuthResponse struct, PPFT/urlPost regex patterns
- Update XblAuthenticate to always use "d=" prefix for OAuth tokens
- Update MicrosoftMCCLogin to use device code flow
- Skip password prompting for Microsoft device code method
- Add translation strings for device code prompts
- Update config comments and documentation
Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/e13b31f3-8d76-4240-bb69-a519a145fc6a
---
MinecraftClient/Program.cs | 8 +-
.../Protocol/MicrosoftAuthentication.cs | 269 +++++++++---------
MinecraftClient/Protocol/ProtocolHandler.cs | 23 +-
.../ConfigComments/ConfigComments.Designer.cs | 2 +-
.../ConfigComments/ConfigComments.resx | 2 +-
.../Translations/Translations.Designer.cs | 18 ++
.../Resources/Translations/Translations.resx | 6 +
docs/guide/configuration.md | 2 +-
8 files changed, 176 insertions(+), 154 deletions(-)
diff --git a/MinecraftClient/Program.cs b/MinecraftClient/Program.cs
index e5ced59f..d9368a95 100644
--- a/MinecraftClient/Program.cs
+++ b/MinecraftClient/Program.cs
@@ -390,6 +390,8 @@ namespace MinecraftClient
//Asking the user to type in missing data such as Username and Password
bool useBrowser = Config.Main.General.AccountType == LoginType.microsoft && Config.Main.General.Method == LoginMethod.browser;
+ bool useDeviceCode = Config.Main.General.AccountType == LoginType.microsoft && Config.Main.General.Method == LoginMethod.mcc;
+ bool skipPassword = useBrowser || useDeviceCode;
if (string.IsNullOrWhiteSpace(InternalConfig.Account.Login) && !useBrowser)
{
ConsoleIO.WriteLine(ConsoleIO.BasicIO ? Translations.mcc_login_basic_io : Translations.mcc_login);
@@ -401,7 +403,7 @@ namespace MinecraftClient
}
}
InternalConfig.Username = InternalConfig.Account.Login;
- if (string.IsNullOrWhiteSpace(InternalConfig.Account.Password) && !useBrowser &&
+ if (string.IsNullOrWhiteSpace(InternalConfig.Account.Password) && !skipPassword &&
(Config.Main.Advanced.SessionCache == CacheType.none || !SessionCache.Contains(ToLowerIfNeed(InternalConfig.Account.Login))))
{
RequestPassword();
@@ -475,7 +477,7 @@ namespace MinecraftClient
if (result != ProtocolHandler.LoginResult.Success
&& string.IsNullOrWhiteSpace(InternalConfig.Account.Password)
- && !(Config.Main.General.AccountType == LoginType.microsoft && Config.Main.General.Method == LoginMethod.browser))
+ && !(Config.Main.General.AccountType == LoginType.microsoft))
RequestPassword();
}
else ConsoleIO.WriteLineFormatted("§8" + string.Format(Translations.mcc_session_valid, session.PlayerName));
@@ -594,7 +596,7 @@ namespace MinecraftClient
}
if ((Config.Main.General.AccountType == LoginType.microsoft || Config.Main.General.AccountType == LoginType.yggdrasil)
- && (InternalConfig.Account.Password != "-" || Config.Main.General.Method == LoginMethod.browser)
+ && InternalConfig.Account.Password != "-"
&& Config.Signature.LoginWithSecureProfile
&& protocolversion >= 759 /* 1.19 and above */
&& !string.IsNullOrWhiteSpace(session.ID))
diff --git a/MinecraftClient/Protocol/MicrosoftAuthentication.cs b/MinecraftClient/Protocol/MicrosoftAuthentication.cs
index cdd9434c..50f2397c 100644
--- a/MinecraftClient/Protocol/MicrosoftAuthentication.cs
+++ b/MinecraftClient/Protocol/MicrosoftAuthentication.cs
@@ -1,13 +1,11 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
-using System.Text.RegularExpressions;
-using static MinecraftClient.Settings;
-using static MinecraftClient.Settings.MainConfigHelper.MainConfig.GeneralConfig;
+using System.Threading;
namespace MinecraftClient.Protocol
{
@@ -16,6 +14,7 @@ namespace MinecraftClient.Protocol
private static readonly string clientId = "54473e32-df8f-42e9-a649-9419b0dab9d3";
private static readonly string signinUrl = string.Format("https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id={0}&response_type=code&redirect_uri=https%3A%2F%2Fmccteam.github.io%2Fredirect.html&scope=XboxLive.signin%20offline_access%20openid%20email&prompt=select_account&response_mode=fragment", clientId);
private static readonly string tokenUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
+ private static readonly string deviceCodeUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
public static string SignInUrl { get { return signinUrl; } }
@@ -53,6 +52,117 @@ namespace MinecraftClient.Protocol
return RequestToken(postData);
}
+ ///
+ /// Initiate the OAuth 2.0 device code flow.
+ /// Returns a device code response containing the user code and verification URI.
+ ///
+ /// Device code response for user to complete authentication
+ public static DeviceCodeResponse RequestDeviceCode()
+ {
+ string postData = string.Format("client_id={0}&scope=XboxLive.signin%20offline_access%20openid%20email", clientId);
+
+ var request = new ProxiedWebRequest(deviceCodeUrl)
+ {
+ UserAgent = "MCC/" + Program.Version
+ };
+ var response = request.Post("application/x-www-form-urlencoded", postData);
+ var jsonData = Json.ParseJson(response.Body);
+
+ if (jsonData?["error"] is not null)
+ {
+ throw new Exception(jsonData["error_description"]!.GetStringValue());
+ }
+
+ return new DeviceCodeResponse()
+ {
+ DeviceCode = jsonData!["device_code"]!.GetStringValue(),
+ UserCode = jsonData["user_code"]!.GetStringValue(),
+ VerificationUri = jsonData["verification_uri"]!.GetStringValue(),
+ ExpiresIn = int.Parse(jsonData["expires_in"]!.GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture),
+ Interval = int.Parse(jsonData["interval"]!.GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture),
+ Message = jsonData["message"]!.GetStringValue()
+ };
+ }
+
+ ///
+ /// Poll the token endpoint until the user completes device code authentication.
+ /// Handles authorization_pending, slow_down, and expiration.
+ ///
+ /// Device code from
+ /// Expiration time in seconds
+ /// Polling interval in seconds
+ /// Login response with access token and refresh token
+ public static LoginResponse PollDeviceCodeToken(string deviceCode, int expiresIn, int interval)
+ {
+ string postData = string.Format(
+ "client_id={0}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code={1}",
+ clientId, deviceCode);
+
+ var stopwatch = Stopwatch.StartNew();
+ int pollInterval = interval;
+
+ while (stopwatch.Elapsed.TotalSeconds < expiresIn)
+ {
+ Thread.Sleep(pollInterval * 1000);
+
+ var request = new ProxiedWebRequest(tokenUrl)
+ {
+ UserAgent = "MCC/" + Program.Version
+ };
+ var response = request.Post("application/x-www-form-urlencoded", postData);
+ var jsonData = Json.ParseJson(response.Body);
+
+ if (jsonData?["error"] is not null)
+ {
+ string error = jsonData["error"]!.GetStringValue();
+
+ if (error == "authorization_pending")
+ {
+ // User hasn't completed auth yet, keep polling
+ continue;
+ }
+ else if (error == "slow_down")
+ {
+ // Server asked us to slow down, increase interval by 5 seconds
+ pollInterval += 5;
+ continue;
+ }
+ else if (error == "expired_token")
+ {
+ throw new Exception("Device code expired. Please try again.");
+ }
+ else if (error == "authorization_declined")
+ {
+ throw new Exception("Authorization was declined by the user.");
+ }
+ else
+ {
+ throw new Exception(jsonData["error_description"]!.GetStringValue());
+ }
+ }
+
+ // Success - parse the token response
+ string accessToken = jsonData!["access_token"]!.GetStringValue();
+ string refreshToken = jsonData["refresh_token"]!.GetStringValue();
+ int tokenExpiresIn = int.Parse(jsonData["expires_in"]!.GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture);
+
+ // Extract email from JWT id_token
+ string payload = JwtPayloadDecode.GetPayload(jsonData["id_token"]!.GetStringValue());
+ var jsonPayload = Json.ParseJson(payload);
+ string email = jsonPayload!["email"]!.GetStringValue();
+
+ return new LoginResponse()
+ {
+ Email = email,
+ AccessToken = accessToken,
+ RefreshToken = refreshToken,
+ ExpiresIn = tokenExpiresIn
+ };
+ }
+
+ throw new Exception("Device code authentication timed out.");
+ }
+
///
/// Perform request to obtain access token by code or by refresh token
///
@@ -70,13 +180,13 @@ namespace MinecraftClient.Protocol
// Error handling
if (jsonData?["error"] is not null)
{
- throw new Exception(jsonData["error_description"].GetStringValue());
+ throw new Exception(jsonData["error_description"]!.GetStringValue());
}
else
{
string accessToken = jsonData!["access_token"]!.GetStringValue();
string refreshToken = jsonData["refresh_token"]!.GetStringValue();
- int expiresIn = int.Parse(jsonData["expires_in"].GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture);
+ int expiresIn = int.Parse(jsonData["expires_in"]!.GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture);
// Extract email from JWT
string payload = JwtPayloadDecode.GetPayload(jsonData["id_token"]!.GetStringValue());
@@ -132,132 +242,25 @@ namespace MinecraftClient.Protocol
public string RefreshToken;
public int ExpiresIn;
}
+
+ public struct DeviceCodeResponse
+ {
+ public string DeviceCode;
+ public string UserCode;
+ public string VerificationUri;
+ public int ExpiresIn;
+ public int Interval;
+ public string Message;
+ }
}
static class XboxLive
{
- private static readonly string authorize = "https://login.live.com/oauth20_authorize.srf?client_id=000000004C12AE6F&redirect_uri=https://login.live.com/oauth20_desktop.srf&scope=service::user.auth.xboxlive.com::MBI_SSL&display=touch&response_type=token&locale=en";
private static readonly string xbl = "https://user.auth.xboxlive.com/user/authenticate";
private static readonly string xsts = "https://xsts.auth.xboxlive.com/xsts/authorize";
private static readonly string userAgent = "Mozilla/5.0 (XboxReplay; XboxLiveAuth/3.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36";
- private static readonly Regex ppft = new("sFTTag:'.*value=\"(.*)\"\\/>'");
- private static readonly Regex urlPost = new("urlPost:'(.+?(?=\'))");
- private static readonly Regex confirm = new("identity\\/confirm");
- private static readonly Regex invalidAccount = new("Sign in to", RegexOptions.IgnoreCase);
- private static readonly Regex twoFA = new("Help us protect your account", RegexOptions.IgnoreCase);
-
- public static string SignInUrl { get { return authorize; } }
-
- ///
- /// Pre-authentication
- ///
- /// This step is to get the login page for later use
- ///
- public static PreAuthResponse PreAuth()
- {
- var request = new ProxiedWebRequest(authorize)
- {
- UserAgent = userAgent
- };
- var response = request.Get();
-
- string html = response.Body;
-
- string PPFT = ppft.Match(html).Groups[1].Value;
- string urlPost = XboxLive.urlPost.Match(html).Groups[1].Value;
-
- if (string.IsNullOrEmpty(PPFT) || string.IsNullOrEmpty(urlPost))
- {
- throw new Exception("Fail to extract PPFT or urlPost");
- }
- //Console.WriteLine("PPFT: {0}", PPFT);
- //Console.WriteLine();
- //Console.WriteLine("urlPost: {0}", urlPost);
-
- return new PreAuthResponse()
- {
- UrlPost = urlPost,
- PPFT = PPFT,
- Cookie = response.Cookies
- };
- }
-
- ///
- /// Perform login request
- ///
- /// This step is to send the login request by using the PreAuth response
- /// Microsoft account email
- /// Account password
- ///
- ///
- public static Microsoft.LoginResponse UserLogin(string email, string password, PreAuthResponse preAuth)
- {
- var request = new ProxiedWebRequest(preAuth.UrlPost, preAuth.Cookie)
- {
- UserAgent = userAgent
- };
-
- string postData = "login=" + Uri.EscapeDataString(email)
- + "&loginfmt=" + Uri.EscapeDataString(email)
- + "&passwd=" + Uri.EscapeDataString(password)
- + "&PPFT=" + Uri.EscapeDataString(preAuth.PPFT);
-
- var response = request.Post("application/x-www-form-urlencoded", postData);
-
- if (Settings.Config.Logging.DebugMessages)
- {
- ConsoleIO.WriteLine(response.ToString());
- }
-
- if (response.StatusCode >= 300 && response.StatusCode <= 399)
- {
- string url = response.Headers.Get("Location")!;
- string hash = url.Split('#')[1];
-
- var request2 = new ProxiedWebRequest(url);
- var response2 = request2.Get();
-
- if (response2.StatusCode != 200)
- {
- throw new Exception("Authentication failed");
- }
-
- if (string.IsNullOrEmpty(hash))
- {
- throw new Exception("Cannot extract access token");
- }
- var dict = Request.ParseQueryString(hash);
-
- //foreach (var pair in dict)
- //{
- // Console.WriteLine("{0}: {1}", pair.Key, pair.Value);
- //}
-
- return new Microsoft.LoginResponse()
- {
- Email = email,
- AccessToken = dict["access_token"],
- RefreshToken = dict["refresh_token"],
- ExpiresIn = int.Parse(dict["expires_in"], NumberStyles.Any, CultureInfo.CurrentCulture)
- };
- }
- else
- {
- if (twoFA.IsMatch(response.Body))
- {
- // TODO: Handle 2FA
- throw new Exception("2FA enabled but not supported yet. Use browser sign-in method or try to disable 2FA in Microsoft account settings");
- }
- else if (invalidAccount.IsMatch(response.Body))
- {
- throw new Exception("Invalid credentials. Check your credentials");
- }
- else throw new Exception("Unexpected response. Check your credentials. Response code: " + response.StatusCode);
- }
- }
-
///
/// Xbox Live Authenticate
///
@@ -272,13 +275,8 @@ namespace MinecraftClient.Protocol
};
request.Headers.Add("x-xbl-contract-version", "0");
- var accessToken = loginResponse.AccessToken;
- if (Config.Main.General.Method == LoginMethod.browser)
- {
- // Our own client ID must have d= in front of the token or HTTP status 400
- // "Stolen" client ID must not have d= in front of the token or HTTP status 400
- accessToken = "d=" + accessToken;
- }
+ // OAuth tokens from our own client ID require "d=" prefix for XBL authentication
+ var accessToken = "d=" + loginResponse.AccessToken;
string payload = "{"
+ "\"Properties\": {"
@@ -297,8 +295,6 @@ namespace MinecraftClient.Protocol
if (response.StatusCode == 200)
{
string jsonString = response.Body;
- //Console.WriteLine(jsonString);
-
var json = Json.ParseJson(jsonString);
string token = json!["Token"]!.GetStringValue();
string userHash = json["DisplayClaims"]!["xui"]![0]!["uhs"]!.GetStringValue();
@@ -317,7 +313,7 @@ namespace MinecraftClient.Protocol
///
/// XSTS Authenticate
///
- /// (Don't ask me what is XSTS, I DONT KNOW)
+ /// Xbox Secure Token Service - exchanges XBL token for a service-specific XSTS token
///
///
public static XSTSAuthenticateResponse XSTSAuthenticate(XblAuthenticateResponse xblResponse)
@@ -378,13 +374,6 @@ namespace MinecraftClient.Protocol
}
}
- public struct PreAuthResponse
- {
- public string UrlPost;
- public string PPFT;
- public NameValueCollection Cookie;
- }
-
public struct XblAuthenticateResponse
{
public string Token;
diff --git a/MinecraftClient/Protocol/ProtocolHandler.cs b/MinecraftClient/Protocol/ProtocolHandler.cs
index 7d395876..7a58c400 100644
--- a/MinecraftClient/Protocol/ProtocolHandler.cs
+++ b/MinecraftClient/Protocol/ProtocolHandler.cs
@@ -776,20 +776,27 @@ namespace MinecraftClient.Protocol
}
///
- /// Sign-in to Microsoft Account without using browser. Only works if 2FA is disabled.
- /// Might not work well in some rare cases.
+ /// Sign-in to Microsoft Account using OAuth 2.0 device code flow.
+ /// Supports accounts with 2FA enabled.
///
- ///
- ///
+ /// Email hint (unused in device code flow, kept for API compatibility)
+ /// Password (unused in device code flow, kept for API compatibility)
///
///
private static LoginResult MicrosoftMCCLogin(string email, string password, out SessionToken session)
{
try
{
- var msaResponse = XboxLive.UserLogin(email, password, XboxLive.PreAuth());
- // Remove refresh token for MCC sign method
- msaResponse.RefreshToken = string.Empty;
+ var deviceCode = Microsoft.RequestDeviceCode();
+
+ ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_device_code_prompt, deviceCode.VerificationUri, deviceCode.UserCode));
+
+ // Try to open the verification URL in the user's browser
+ Microsoft.OpenBrowser(deviceCode.VerificationUri);
+
+ ConsoleIO.WriteLineFormatted(Translations.mcc_device_code_waiting);
+
+ var msaResponse = Microsoft.PollDeviceCodeToken(deviceCode.DeviceCode, deviceCode.ExpiresIn, deviceCode.Interval);
return MicrosoftLogin(msaResponse, out session);
}
catch (Exception e)
@@ -801,7 +808,7 @@ namespace MinecraftClient.Protocol
ConsoleIO.WriteLineFormatted("§c" + e.StackTrace);
}
- return LoginResult.WrongPassword; // Might not always be wrong password
+ return LoginResult.WrongPassword;
}
}
diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs
index 9ccbede3..91cd1cb1 100644
--- a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs
+++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs
@@ -1922,7 +1922,7 @@ namespace MinecraftClient {
}
///
- /// Looks up a localized string similar to Microsoft Account sign-in method: "mcc" OR "browser". If the login always fails, please try to use the "browser" once..
+ /// Looks up a localized string similar to Microsoft Account sign-in method: "mcc" (device code, supports 2FA) OR "browser" (manual browser login)..
///
internal static string Main_General_method {
get {
diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx
index 9c6152df..edbfc7af 100644
--- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx
+++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx
@@ -724,7 +724,7 @@ Usage examples: "/tell <mybot> connect Server1", "/connect Server2"The address of the game server, "Host" can be filled in with domain name or IP address. (The "Port" field can be deleted, it will be resolved automatically)
- Microsoft Account sign-in method: "mcc" OR "browser". If the login always fails, please try to use the "browser" once.
+ Microsoft Account sign-in method: "mcc" (device code, supports 2FA) OR "browser" (manual browser login).
Account type: "mojang" OR "microsoft" OR "yggdrasil". Also affects interactive login in console.
diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs
index d1f31606..0c9f0542 100644
--- a/MinecraftClient/Resources/Translations/Translations.Designer.cs
+++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs
@@ -5498,6 +5498,24 @@ namespace MinecraftClient {
}
}
+ ///
+ /// Looks up a localized string similar to To sign in, open {0} in your browser and enter the code: {1}.
+ ///
+ internal static string mcc_device_code_prompt {
+ get {
+ return ResourceManager.GetString("mcc.device_code_prompt", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Waiting for authentication to complete....
+ ///
+ internal static string mcc_device_code_waiting {
+ get {
+ return ResourceManager.GetString("mcc.device_code_waiting", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Login failed :.
///
diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx
index b16e7620..a3e49c9e 100644
--- a/MinecraftClient/Resources/Translations/Translations.resx
+++ b/MinecraftClient/Resources/Translations/Translations.resx
@@ -1851,6 +1851,12 @@ You can use "/chunk status {0:0.0} {1:0.0} {2:0.0}" to check the chunk loading s
Connecting to {0}...
+
+ To sign in, open {0} in your browser and enter the code: §e{1}
+
+
+ Waiting for authentication to complete...
+
Login failed :
diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md
index 1876bbaa..d7e378b3 100644
--- a/docs/guide/configuration.md
+++ b/docs/guide/configuration.md
@@ -131,7 +131,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Description:**
- This setting is where you define the way you will sign in with your Microsoft account, available options are `mcc` and `browser`.
+ This setting is where you define the way you will sign in with your Microsoft account, available options are `mcc` and `browser`. The `mcc` method uses the OAuth 2.0 device code flow: MCC will display a code and a URL, and you complete the sign-in (including 2FA) in your browser. The `browser` method opens a sign-in page in your browser and you paste the resulting code back into MCC.
- **Type:** `string`