-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 3674454
Showing
12 changed files
with
7,136 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
# minecraft.nix | ||
|
||
Inspired by [this thread](https://discourse.nixos.org/t/minecraft-launcher-in-pure-nix-all-mc-versions/3937?u=ninlives), this flake contains derivations of both vanilla and fabric edition (if available) for all versions of minecraft. | ||
|
||
(Old versions are not fully tested, feel free to file a issue if your encounter problems.) | ||
|
||
# USAGE | ||
|
||
## Vanilla | ||
|
||
```sh | ||
$ nix run github:Ninlives/minecraft.nix#v1_18_1.vanilla.client | ||
``` | ||
|
||
You will be asked to login before launch the game. | ||
Only MSA login is supported, since Microsoft has started to migrate all Mojang accounts to Microsoft accounts. | ||
|
||
## Fabric | ||
|
||
```sh | ||
$ nix run github:Ninlives/minecraft.nix#v1_18_1.fabric.client | ||
``` | ||
|
||
A utility function is also provided to define loaded mods in the nix way: | ||
|
||
```nix | ||
{ | ||
description = "A simple modpack."; | ||
inputs.minecraft.url = "github:Ninlives/minecraft.nix"; | ||
inputs.flake-utils.url = "github:numtide/flake-utils"; | ||
outputs = { self, minecraft, flake-utils }: | ||
flake-utils.lib.eachDefaultSystem (system: { | ||
packages.minecraft-with-ae2 = (minecraft.v1_18_1.fabric.client.withMods [ | ||
(builtins.fetchurl { | ||
name = "fabric-api"; | ||
url = | ||
"https://media.forgecdn.net/files/3609/610/fabric-api-0.46.1%2B1.18.jar"; | ||
sha256 = | ||
"sha256:0d6dw9lsryy51by9iypcg2mk1p1ixf0bd3dblfgmv6nx8g98whlh"; | ||
}) | ||
# the withMods function is also composable | ||
]).withMods [ | ||
(builtins.fetchurl { | ||
url = | ||
"https://media.forgecdn.net/files/3609/46/appliedenergistics2-10.0.0.jar"; | ||
sha256 = | ||
"sha256:0v7nw98b22lbwyd5qy71w93rj7sh7ps30g4cb38s3g3n997yk49n"; | ||
}) | ||
]; | ||
}); | ||
} | ||
``` | ||
|
||
# TODO | ||
|
||
- [ ] `withResourcePacks` | ||
- [ ] Configure Minecraft and mods in nix |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
import json | ||
from datetime import datetime | ||
from os.path import expanduser | ||
from sys import argv | ||
from pathlib import Path | ||
from colorama import Fore, Style | ||
|
||
|
||
def get_mc_token_from_ms_token(ms_token): | ||
info("Logging in as an Xbox user.") | ||
(xbl_token, user_hash) = get_xbl_token_and_userhash(ms_token) | ||
info("Getting authorization to access Xbox services.") | ||
xsts_token = get_xsts_token(xbl_token, user_hash) | ||
info("Getting authorization to access Mojang services.") | ||
return get_mc_token(xsts_token, user_hash) | ||
|
||
|
||
def login_and_get_profile(): | ||
info("Logging in with Microsoft account.") | ||
(ms_token, refresh_token) = get_ms_token() | ||
mc_token = get_mc_token_from_ms_token(ms_token) | ||
|
||
info("Determining game ownership.") | ||
if check_ownership(mc_token): | ||
profile = get_profile(mc_token) | ||
profile['mc_token'] = mc_token | ||
profile['refresh_token'] = refresh_token | ||
return profile | ||
else: | ||
raise AuthFailed("User does not own the game") | ||
|
||
|
||
def refresh(profile): | ||
info("Logging in with Microsoft refresh token.") | ||
(new_ms_token, new_refresh_token) = refresh_ms_token(profile['refresh_token']) | ||
new_mc_token = get_mc_token_from_ms_token(new_ms_token) | ||
profile['mc_token'] = new_mc_token | ||
profile['refresh_token'] = new_refresh_token | ||
|
||
|
||
def custom_encode(obj): | ||
if isinstance(obj, Token): | ||
return {'__type': 'Token', '__value': obj.value, '__not_after': obj.not_after.isoformat()} | ||
return json.JSONEncoder.default(obj) | ||
|
||
|
||
def custom_decode(dct): | ||
if '__type' in dct and dct['__type'] == 'Token': | ||
return Token(dct['__value'], datetime.fromisoformat(dct['__not_after'])) | ||
return dct | ||
|
||
|
||
def authenticate(profile_path): | ||
if profile_path.exists(): | ||
try: | ||
with open(profile_path) as f: | ||
profile = json.load(f, object_hook=custom_decode) | ||
except json.JSONDecodeError: | ||
error(f"{profile_path} seems to be corrupted, try to login again.") | ||
else: | ||
mc_token = profile['mc_token'] | ||
if mc_token.not_after < datetime.utcnow(): | ||
refresh(profile) | ||
with open(profile_path, 'w+') as f: | ||
json.dump(profile, f, default=custom_encode) | ||
return profile | ||
|
||
profile = login_and_get_profile() | ||
profile_path.parent.mkdir(parents=True, exist_ok=True) | ||
with open(profile_path, 'w+') as f: | ||
json.dump(profile, f, default=custom_encode) | ||
return profile | ||
|
||
|
||
try: | ||
profile_path_name = expanduser('~/.local/share/minecraft.nix/profile.json') | ||
for i in range(len(argv)): | ||
if argv[i] == '--profile' and i + 1 < len(argv): | ||
profile_path_name = expanduser(argv[i + 1]) | ||
break | ||
authenticate(Path(profile_path_name)) | ||
info("Successfully authenticated.") | ||
exit(0) | ||
except Exception as e: | ||
error(f"Authentication Failed: {type(e).__name__}: {e}.") | ||
exit(1) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
from requests import get, post | ||
from requests.exceptions import Timeout | ||
from time import sleep, time | ||
from datetime import datetime, timedelta | ||
from uuid import uuid4 as uuid | ||
from typing import Dict | ||
import re | ||
import jwt | ||
|
||
CLIENT_ID = "@CLIENT_ID@" | ||
# Microsoft login | ||
DEVICE_CODE_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode" | ||
MS_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" | ||
SCOPE = "XboxLive.signin offline_access" | ||
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" | ||
# Xbox Live | ||
XBL_AUTH_URL = "https://user.auth.xboxlive.com/user/authenticate" | ||
XBL_SITE_NAME = "user.auth.xboxlive.com" | ||
XBL_RELYING_PARTY = "http://auth.xboxlive.com" | ||
# XSTS | ||
XSTS_AUTH_URL = "https://xsts.auth.xboxlive.com/xsts/authorize" | ||
XSTS_RELYING_PARTY = "rp://api.minecraftservices.com/" | ||
|
||
MC_LOGIN_URL = "https://api.minecraftservices.com/launcher/login" | ||
ENTITLEMENTS_URL = "https://api.minecraftservices.com/entitlements/license" | ||
PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile" | ||
MOJANG_PUBLIC_KEY = """ | ||
-----BEGIN PUBLIC KEY----- | ||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtz7jy4jRH3psj5AbVS6W | ||
NHjniqlr/f5JDly2M8OKGK81nPEq765tJuSILOWrC3KQRvHJIhf84+ekMGH7iGlO | ||
4DPGDVb6hBGoMMBhCq2jkBjuJ7fVi3oOxy5EsA/IQqa69e55ugM+GJKUndLyHeNn | ||
X6RzRzDT4tX/i68WJikwL8rR8Jq49aVJlIEFT6F+1rDQdU2qcpfT04CBYLM5gMxE | ||
fWRl6u1PNQixz8vSOv8pA6hB2DU8Y08VvbK7X2ls+BiS3wqqj3nyVWqoxrwVKiXR | ||
kIqIyIAedYDFSaIq5vbmnVtIonWQPeug4/0spLQoWnTUpXRZe2/+uAKN1RY9mmaB | ||
pRFV/Osz3PDOoICGb5AZ0asLFf/qEvGJ+di6Ltt8/aaoBuVw+7fnTw2BhkhSq1S/ | ||
va6LxHZGXE9wsLj4CN8mZXHfwVD9QG0VNQTUgEGZ4ngf7+0u30p7mPt5sYy3H+Fm | ||
sWXqFZn55pecmrgNLqtETPWMNpWc2fJu/qqnxE9o2tBGy/MqJiw3iLYxf7U+4le4 | ||
jM49AUKrO16bD1rdFwyVuNaTefObKjEMTX9gyVUF6o7oDEItp5NHxFm3CqnQRmch | ||
HsMs+NxEnN4E9a8PDB23b4yjKOQ9VHDxBxuaZJU60GBCIOF9tslb7OAkheSJx5Xy | ||
EYblHbogFGPRFU++NrSQRX0CAwEAAQ== | ||
-----END PUBLIC KEY----- | ||
""" | ||
|
||
|
||
class AuthFailed(Exception): | ||
""" Authentication Failed Exception """ | ||
|
||
|
||
class Token(): | ||
def __init__(self, value: str, not_after: datetime): | ||
self.value = value | ||
self.not_after = not_after | ||
|
||
def __str__(self): | ||
return self.value | ||
|
||
|
||
def prompt(msg): | ||
print(Fore.YELLOW + msg + Style.RESET_ALL) | ||
|
||
|
||
def info(msg): | ||
print(Style.DIM + msg + Style.RESET_ALL) | ||
|
||
|
||
def error(msg): | ||
print(Fore.RED + msg + Style.RESET_ALL) | ||
|
||
|
||
def parse_timestamp(value) -> datetime: | ||
# The datetime module does not fully understand ISO 8601 | ||
timestamp = re.match(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", value) | ||
if not timestamp: | ||
raise AuthFailed("Unrecogonized timestamp") | ||
return datetime.fromisoformat(timestamp.group(0)) | ||
|
||
|
||
def get_ms_token() -> (Token, Token): | ||
response = post(DEVICE_CODE_URL, data={'client_id': CLIENT_ID, 'scope': SCOPE}).json() | ||
|
||
start_time = time() | ||
|
||
device_code = response['device_code'] | ||
expires_in = int(response['expires_in']) | ||
interval = int(response['interval']) | ||
|
||
prompt(response['message']) | ||
|
||
info("Waiting for authentication...") | ||
while True: | ||
if time() - start_time > expires_in: | ||
raise AuthFailed("Authentication takes too long to finish.") | ||
try: | ||
response = post(MS_TOKEN_URL, data={'client_id': CLIENT_ID, 'code': device_code, 'grant_type': GRANT_TYPE}).json() | ||
if 'error' in response: | ||
if response['error'] == 'slow_down': | ||
interval = interval + 5 | ||
elif response['error'] != 'authorization_pending': | ||
error(response['error_description']) | ||
raise AuthFailed('Login to Microsoft account failed') | ||
else: | ||
break | ||
except Timeout as te: | ||
error(str(te)) | ||
interval = interval * 2 | ||
|
||
sleep(interval) | ||
|
||
access_token = Token(response['access_token'], datetime.utcnow() + timedelta(seconds=int(response['expires_in']))) | ||
refresh_token = Token(response['refresh_token'], datetime.min) | ||
return (access_token, refresh_token) | ||
|
||
|
||
def refresh_ms_token(refresh_token: Token) -> (Token, Token): | ||
response = post(MS_TOKEN_URL, data={'client_id': CLIENT_ID, 'refresh_token': refresh_token.value, 'grant_type': 'refresh_token'}) | ||
access_token = Token(response['access_token'], datetime.utcnow() + timedelta(seconds=int(response['expires_in']))) | ||
refresh_token = Token(response['refresh_token'], datetime.min) | ||
return (access_token, refresh_token) | ||
|
||
|
||
def get_xbl_token_and_userhash(ms_token: Token) -> (Token, str): | ||
response = post(XBL_AUTH_URL, json={ | ||
"Properties": { | ||
"AuthMethod": "RPS", | ||
"SiteName": XBL_SITE_NAME, | ||
"RpsTicket": f"d={ms_token}" | ||
}, | ||
"RelyingParty": XBL_RELYING_PARTY, | ||
"TokenType": "JWT" | ||
}).json() | ||
|
||
for claim in response["DisplayClaims"]["xui"]: | ||
if "uhs" in claim: | ||
user_hash = claim["uhs"] | ||
break | ||
|
||
if not user_hash: | ||
raise AuthFailed("User hash not found") | ||
|
||
return (Token(response['Token'], parse_timestamp(response['NotAfter'])), user_hash) | ||
|
||
|
||
def get_xsts_token(xbl_token: Token, user_hash: str) -> Token: | ||
response = post(XSTS_AUTH_URL, json={ | ||
"Properties": { | ||
"SandboxId": "RETAIL", | ||
"UserTokens": [ | ||
xbl_token.value | ||
] | ||
}, | ||
"RelyingParty": XSTS_RELYING_PARTY, | ||
"TokenType": "JWT" | ||
}).json() | ||
|
||
if "Xerr" in response: | ||
err_code = response["Xerr"] | ||
if err_code == 2148916233: | ||
raise AuthFailed("The account doesn't have an Xbox account") | ||
elif err_code == 2148916235: | ||
raise AuthFailed("The account is from a country where Xbox Live is not available/banned") | ||
elif err_code == 2148916238: | ||
raise AuthFailed("The account is a child (under 18) and cannot proceed unless the account is added to a Family by an adult") | ||
else: | ||
raise AuthFailed(f"Unknown error from XSTS: {err_code}") | ||
|
||
uhs_found = False | ||
for claim in response["DisplayClaims"]["xui"]: | ||
if "uhs" in claim: | ||
uhs_found = True | ||
if claim["uhs"] != user_hash: | ||
raise AuthFailed("User hash changed, something is wrong on the server side.") | ||
break | ||
|
||
if not uhs_found: | ||
raise RuntimeError("User hash not found") | ||
|
||
return Token(response["Token"], parse_timestamp(response["NotAfter"])) | ||
|
||
|
||
def get_mc_token(xsts_token: Token, user_hash: str) -> Token: | ||
response = post(MC_LOGIN_URL, json={ | ||
"xtoken": f"XBL3.0 x={user_hash};{xsts_token}", | ||
"platform": "PC_LAUNCHER" | ||
}).json() | ||
|
||
return Token(response['access_token'], datetime.utcnow() + timedelta(seconds=int(response['expires_in']))) | ||
|
||
|
||
def check_ownership(mc_token: Token): | ||
response = get(f"{ENTITLEMENTS_URL}?requestId={uuid()}", headers={'Authorization': f"Bearer {mc_token}"}).json() | ||
signature = response['signature'] | ||
decoded = jwt.decode(signature, MOJANG_PUBLIC_KEY, algorithms=["RS256"]) | ||
if decoded['requestId'] != response['requestId']: | ||
raise AuthFailed("Incorrect signature") | ||
for item in response['items']: | ||
if 'name' in item and (item['name'] == 'product_minecraft' or item['name'] == 'game_minecraft'): | ||
return True | ||
return False | ||
|
||
|
||
def get_profile(mc_token: Token) -> Dict[str, str]: | ||
return get(PROFILE_URL, headers={'Authorization': f"Bearer {mc_token}"}).json() | ||
|
Oops, something went wrong.