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)
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user