""" 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, )