Fix "Missing javaVersion" error when launching legacy Minecraft. Add settings page. (But is not fully implemented yet)
465 lines
17 KiB
Python
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,
|
|
)
|
|
|