Files
Launcher/core_lib/game/authentication.py
T
wei 6ab807356d Add account manager and the account page is now fully functional.
Fix "Missing javaVersion" error when launching legacy Minecraft.

Add settings page. (But is not fully implemented yet)
2026-08-03 00:16:12 +08:00

465 lines
17 KiB
Python

"""
Store all function for microsoft authentication
WARNING: This module is vibed! With code reviewed!
"""
import logging
import time
import traceback
from typing import Callable
import requests
from core_lib.common.exception import AuthenticationSessionException, AuthenticationConnectException, \
AuthenticationException
MICROSOFT_DEVICE_CODE_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
MICROSOFT_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
XBOX_AUTH_URL = "https://user.auth.xboxlive.com/user/authenticate"
XSTS_AUTH_URL = "https://xsts.auth.xboxlive.com/xsts/authorize"
MINECRAFT_LOGIN_URL = "https://api.minecraftservices.com/authentication/login_with_xbox"
MINECRAFT_ENTITLEMENTS_URL = "https://api.minecraftservices.com/entitlements/mcstore"
MINECRAFT_PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile"
logger = logging.getLogger("Launcher.CoreLib")
def auth_exception_handler(request_handler: Callable, request_type: str = "without_token", stage: str = "authentication",):
"""
Handle authentication exceptions
:param stage:
:param request_handler:
:param request_type: Set this will change exception type when an error occurs. ("without_token" for process that
don't use token, "with_token" for process that use token. If with token, exception usually throw
AuthenticationSessionException. If not, throw AuthenticationException instead)
:return:
"""
exception_type = (
AuthenticationSessionException
if request_type == "with_token"
else AuthenticationException
)
try:
return request_handler()
except requests.exceptions.HTTPError as e:
status = e.response.status_code if e.response is not None else "unknown"
response_data = {}
if e.response is not None:
try:
value = e.response.json()
if isinstance(value, dict):
response_data = value
except ValueError:
pass
xerr = response_data.get("XErr")
try:
xerr = int(xerr) if xerr is not None else None
except (TypeError, ValueError):
pass
# Below are the xsts error code suggestions. Why return that value (error is integer not string) ?
# Maybe it makes international support more easily?
xsts_suggestions = {
2148916233: "This Microsoft account does not have an Xbox profile. Sign in at xbox.com and create one, then try again.",
2148916235: "Xbox Live is not available for this account's country or region.",
2148916236: "This account must complete adult verification on the Xbox website.",
2148916237: "This account must complete age verification on the Xbox website.",
2148916238: "This is a child account. An adult must add it to a Microsoft family and allow Xbox access.",
}
if xerr in xsts_suggestions:
raise exception_type(
f"{stage} failed with HTTP {status}, XErr {xerr}",
user_message=f"{stage} failed (Xbox error {xerr}). {xsts_suggestions[xerr]}",
) from e
server_message = response_data.get("Message") or response_data.get("error_description")
# This suggestions can make user happy instead of seeing an HTTP error code
suggestions = {
400: "The authentication request or session was rejected.",
401: "The login session is invalid or expired. Sign in again.",
403: "This account is not allowed to complete this step. Check Xbox privacy settings and account restrictions."
" Or check the client id has authorized to use Minecraft API.",
429: "The authentication service received too many requests. Wait a moment and try again.",
}
suggestion = suggestions.get(
status,
"The authentication service returned an unexpected response. Try again later.",
)
if server_message:
suggestion = f"{suggestion} Server message: {server_message}"
raise exception_type(f"{stage} failed with HTTP status {status}: {e}",
user_message=f"{stage} failed (HTTP {status}). {suggestion}",
) from e
except requests.exceptions.ConnectionError as e:
raise AuthenticationConnectException(
f"Unable to connect during {stage}: {e}",
user_message=f"{stage} could not connect to the authentication server. Check your internet connection, proxy, and firewall.",
) from e
except requests.exceptions.Timeout as e:
raise AuthenticationConnectException(
f"Request timed out during {stage}: {e}",
user_message=f"{stage} timed out. Check your connection and try again later.",
) from e
except requests.exceptions.RequestException as e:
raise exception_type(
f"Request error during {stage}: {e}",
user_message=f"{stage} failed because the authentication request could not be completed: {e}",
) from e
except AuthenticationException:
raise
except Exception as e:
raise exception_type(
f"Unexpected error during {stage}: {traceback.format_exc()}",
user_message=f"{stage} failed because the server returned invalid or unexpected data.",
) from e
def get_device_code(client_id: str) -> tuple[str, dict]:
"""
Get xbox live device code
:param client_id:
:return:
device_code: str
json_data: dict
"""
def request_handler():
body = {
"client_id": client_id,
"scope": "XboxLive.signin offline_access",
}
# Microsoft OAuth endpoints require form-encoded request bodies.
r = requests.post(MICROSOFT_DEVICE_CODE_URL, data=body)
r.raise_for_status()
return r.json()
data = auth_exception_handler(request_handler, stage="Requesting a Microsoft sign-in code")
if not data.get("device_code"):
raise AuthenticationException(
"Authentication response missing device_code",
user_message="Microsoft did not return a sign-in code. Check the client ID or update the launcher.",
)
return data["device_code"], data
def get_microsoft_token(client_id: str, device_code: str, interval: int, expires_in: float, cancelled=None) -> tuple[str, str, dict]:
"""
Get microsoft token (with login wait)
:param client_id:
:param device_code:
:param interval:
:param expires_in:
:param cancelled:
:return:
access_token: str
refresh_token: str
json_data: dict
"""
def _request_handler():
r = requests.post(MICROSOFT_TOKEN_URL, data={
"client_id": client_id,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code,
})
return r.status_code, r.json()
def verify_data(result: dict) -> tuple[str, str, dict]:
access_token = result.get("access_token")
refresh_token = result.get("refresh_token")
if not access_token:
raise AuthenticationException(
"Authentication response missing access_token",
user_message="Microsoft approved the sign-in but did not return an access token. Please sign in again.",
)
if not refresh_token:
raise AuthenticationException(
"Authentication response missing refresh_token",
user_message="Microsoft did not return a refresh token. Ensure the offline_access permission is enabled, then sign in again.",
)
return access_token, refresh_token, result
deadline = time.monotonic() + expires_in
while time.monotonic() < deadline:
# cancel callback
if cancelled and cancelled():
raise InterruptedError("Login cancelled.")
time.sleep(interval)
status, data = auth_exception_handler(_request_handler, stage="Waiting for Microsoft sign-in approval")
if status == 200:
return verify_data(data)
error = data.get("error")
if error == "authorization_pending":
continue
if error == "slow_down":
interval += 5
continue
description = data.get("error_description") or data.get("error") or "No details were provided"
raise AuthenticationException(
f"Microsoft token request failed with HTTP {status}: {description}",
user_message=f"Microsoft sign-in was rejected (HTTP {status}): {description}",
)
raise TimeoutError("Microsoft sign-in code expired before login was completed. Start the sign-in process again.")
def get_xbox_token_and_user_hash(access_token: str) -> tuple[str, str, dict]:
"""
Get xbox live token and user hash
:param access_token: Microsoft account access token
:return:
xbox_token: str
user_hash: str
json_data: dict
"""
def _request_handler():
r = requests.post(XBOX_AUTH_URL, json={
"Properties": {
"AuthMethod": "RPS",
"SiteName": "user.auth.xboxlive.com",
"RpsTicket": f"d={access_token}",
},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT",
}, headers={"Accept": "application/json"})
r.raise_for_status()
return r.json()
def verify_data(result: dict) -> tuple[str, str, dict]:
xbox_token = result.get("Token")
user_hash = None
try:
user_hash = result.get("DisplayClaims", {}).get("xui", {})[0].get("uhs")
except Exception as e:
logger.error("Unable to get user hash from data {}: {}".format(result, e))
if not xbox_token:
raise AuthenticationException(
"Authentication response missing xbox_token",
user_message="Xbox Live accepted the sign-in but did not return an authentication token. Please sign in again.",
)
if not user_hash:
raise AuthenticationException(
"Authentication response missing user_hash",
user_message="Xbox Live did not return the account identifier. Check the account's Xbox profile and try again.",
)
return xbox_token, user_hash, result
data = auth_exception_handler(_request_handler, stage="Signing in to Xbox Live")
return verify_data(data)
def get_xsts_token(xbox_token: str) -> tuple[str, dict]:
"""
Get xsts token
:param xbox_token:
:return:
xsts_token: str
json_data: dict
"""
def _request_handler():
r = requests.post(XSTS_AUTH_URL, json={
"Properties": {"SandboxId": "RETAIL", "UserTokens": [xbox_token]},
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT",
}, headers={"Accept": "application/json"})
r.raise_for_status()
return r.json()
def verify_data(result: dict) -> tuple[str, dict]:
xsts_token = result.get("Token")
if not xsts_token:
raise AuthenticationException(
"Authentication response missing xsts_token",
user_message="Xbox security authorization did not return a token. The account may need an Xbox profile or may be restricted.",
)
return xsts_token, result
data = auth_exception_handler(
_request_handler,
request_type="with_token",
stage="Authorizing the Xbox session for Minecraft",
)
return verify_data(data)
def get_minecraft_access_token(user_hash: str, xsts_token: str) -> tuple[str, dict]:
"""
Get minecraft access token
:param user_hash: Xbox live user hash
:param xsts_token: XSTS token
Above param can get from get_xbox_token_and_user_hash and get_xsts_token
:return:
access_token: str
json_data: dict
"""
def _request_handler():
r = requests.post(MINECRAFT_LOGIN_URL, json={"identityToken": f"XBL3.0 x={user_hash};{xsts_token}"},
headers={"Accept": "application/json"})
r.raise_for_status()
return r.json()
def verify_data(result: dict) -> tuple[str, dict]:
access_token = result.get("access_token")
if not access_token:
raise AuthenticationException(
"Authentication response missing access token",
user_message="Minecraft services accepted the Xbox session but did not return a game access token. Please sign in again.",
)
return access_token, result
data = auth_exception_handler(
_request_handler,
request_type="with_token",
stage="Signing in to Minecraft services",
)
return verify_data(data)
def get_account_entitlements(minecraft_access_token: str) -> dict:
"""
Get minecraft account entitlements
:param minecraft_access_token:
:return:
"""
def _request_handler():
response = requests.get(
MINECRAFT_ENTITLEMENTS_URL,
headers={"Authorization": f"Bearer {minecraft_access_token}"},
)
response.raise_for_status()
return response.json()
data = auth_exception_handler(
_request_handler,
request_type="with_token",
stage="Checking Minecraft ownership",
)
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
raise AuthenticationSessionException(
"Minecraft entitlement response is missing the items list",
user_message="Minecraft services returned an invalid ownership response. Please sign in again or try later.",
)
return data
def get_minecraft_profile(minecraft_access_token: str) -> dict:
"""
Get minecraft profile
:param minecraft_access_token:
:return:
profile_data: dict
"""
def _request_handler():
response = requests.get(
MINECRAFT_PROFILE_URL,
headers={"Authorization": f"Bearer {minecraft_access_token}"},
)
response.raise_for_status()
return response.json()
data = auth_exception_handler(
_request_handler,
request_type="with_token",
stage="Loading the Minecraft profile",
)
if not isinstance(data, dict) or not data.get("id") or not data.get("name"):
raise AuthenticationSessionException(
"Minecraft profile response is missing id or name",
user_message="Minecraft services did not return a valid player profile. Ensure this account owns Minecraft and has created a profile.",
)
return data
def has_minecraft_license(entitlements: dict) -> bool:
"""
Check if provided account entitlements have minecraft license
:param entitlements:
:return:
found: bool
"""
if not isinstance(entitlements, dict):
raise AuthenticationException(
f"Expected Minecraft entitlements to be a dictionary, got {type(entitlements).__name__}",
user_message="Unable to verify Minecraft ownership because the ownership response is invalid.",
)
items = entitlements.get("items")
if not isinstance(items, list):
raise AuthenticationException(
"Minecraft entitlement response is missing a valid items list",
user_message="Unable to verify Minecraft ownership because the ownership list is missing or invalid.",
)
return any(
isinstance(item, dict)
and item.get("name") in {"game_minecraft", "product_minecraft"}
for item in items
)
def refresh_microsoft_token(client_id: str, refresh_token: str) -> tuple[str, str, dict]:
"""
Use existing microsoft refresh token to get new (or extend the expires time) refresh token
Returns:
microsoft_access_token,
new_refresh_token,
response_data
"""
def request_handler():
response = requests.post(
MICROSOFT_TOKEN_URL,
data={
"client_id": client_id,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "XboxLive.signin offline_access",
},
timeout=30,
)
response.raise_for_status()
return response.json()
result = auth_exception_handler(
request_handler,
request_type="with_token",
stage="Refreshing Microsoft credentials",
)
access_token = result.get("access_token")
new_refresh_token = result.get("refresh_token")
if not access_token:
raise AuthenticationSessionException(
"Refresh response missing access_token",
user_message=(
"Microsoft could not refresh this account. "
"Please sign in again."
),
)
# Return original refresh token if new token is not show up in response
return (
access_token,
new_refresh_token or refresh_token,
result,
)