python-xbox 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_xbox-0.1.0.dist-info/METADATA +217 -0
- python_xbox-0.1.0.dist-info/RECORD +64 -0
- python_xbox-0.1.0.dist-info/WHEEL +4 -0
- python_xbox-0.1.0.dist-info/entry_points.txt +6 -0
- python_xbox-0.1.0.dist-info/licenses/LICENSE +20 -0
- pythonxbox/__init__.py +4 -0
- pythonxbox/api/__init__.py +0 -0
- pythonxbox/api/client.py +166 -0
- pythonxbox/api/language.py +76 -0
- pythonxbox/api/provider/__init__.py +0 -0
- pythonxbox/api/provider/account/__init__.py +73 -0
- pythonxbox/api/provider/account/models.py +11 -0
- pythonxbox/api/provider/achievements/__init__.py +164 -0
- pythonxbox/api/provider/achievements/models.py +133 -0
- pythonxbox/api/provider/baseprovider.py +22 -0
- pythonxbox/api/provider/catalog/__init__.py +86 -0
- pythonxbox/api/provider/catalog/const.py +15 -0
- pythonxbox/api/provider/catalog/models.py +428 -0
- pythonxbox/api/provider/cqs/__init__.py +85 -0
- pythonxbox/api/provider/cqs/models.py +59 -0
- pythonxbox/api/provider/gameclips/__init__.py +167 -0
- pythonxbox/api/provider/gameclips/models.py +58 -0
- pythonxbox/api/provider/lists/__init__.py +71 -0
- pythonxbox/api/provider/lists/models.py +33 -0
- pythonxbox/api/provider/mediahub/__init__.py +64 -0
- pythonxbox/api/provider/mediahub/models.py +82 -0
- pythonxbox/api/provider/message/__init__.py +135 -0
- pythonxbox/api/provider/message/models.py +96 -0
- pythonxbox/api/provider/people/__init__.py +193 -0
- pythonxbox/api/provider/people/models.py +252 -0
- pythonxbox/api/provider/presence/__init__.py +110 -0
- pythonxbox/api/provider/presence/models.py +53 -0
- pythonxbox/api/provider/profile/__init__.py +140 -0
- pythonxbox/api/provider/profile/models.py +47 -0
- pythonxbox/api/provider/ratelimitedprovider.py +79 -0
- pythonxbox/api/provider/screenshots/__init__.py +167 -0
- pythonxbox/api/provider/screenshots/models.py +56 -0
- pythonxbox/api/provider/smartglass/__init__.py +402 -0
- pythonxbox/api/provider/smartglass/models.py +186 -0
- pythonxbox/api/provider/titlehub/__init__.py +143 -0
- pythonxbox/api/provider/titlehub/models.py +106 -0
- pythonxbox/api/provider/usersearch/__init__.py +29 -0
- pythonxbox/api/provider/usersearch/models.py +17 -0
- pythonxbox/api/provider/userstats/__init__.py +164 -0
- pythonxbox/api/provider/userstats/models.py +44 -0
- pythonxbox/authentication/__init__.py +0 -0
- pythonxbox/authentication/manager.py +161 -0
- pythonxbox/authentication/models.py +162 -0
- pythonxbox/authentication/xal.py +348 -0
- pythonxbox/common/__init__.py +0 -0
- pythonxbox/common/exceptions.py +59 -0
- pythonxbox/common/filetimes.py +81 -0
- pythonxbox/common/models.py +34 -0
- pythonxbox/common/ratelimits/__init__.py +268 -0
- pythonxbox/common/ratelimits/models.py +23 -0
- pythonxbox/common/request_signer.py +190 -0
- pythonxbox/common/signed_session.py +60 -0
- pythonxbox/py.typed +0 -0
- pythonxbox/scripts/__init__.py +15 -0
- pythonxbox/scripts/authenticate.py +159 -0
- pythonxbox/scripts/change_gamertag.py +111 -0
- pythonxbox/scripts/friends.py +80 -0
- pythonxbox/scripts/search.py +43 -0
- pythonxbox/scripts/xal.py +113 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Authentication Models."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, UTC
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from pydantic.dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from pythonxbox.common.models import PascalCaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def utc_now() -> datetime:
|
|
12
|
+
return datetime.now(UTC)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class XTokenResponse(PascalCaseModel):
|
|
16
|
+
issue_instant: datetime
|
|
17
|
+
not_after: datetime
|
|
18
|
+
token: str
|
|
19
|
+
|
|
20
|
+
def is_valid(self) -> bool:
|
|
21
|
+
return self.not_after > utc_now()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class XADDisplayClaims(BaseModel):
|
|
25
|
+
# {"xdi": {"did": "F.....", "dcs": "0"}}
|
|
26
|
+
xdi: dict[str, str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class XADResponse(XTokenResponse):
|
|
30
|
+
display_claims: XADDisplayClaims
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class XATDisplayClaims(BaseModel):
|
|
34
|
+
xti: dict[str, str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class XATResponse(XTokenResponse):
|
|
38
|
+
display_claims: XATDisplayClaims
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class XAUDisplayClaims(BaseModel):
|
|
42
|
+
xui: list[dict[str, str]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class XAUResponse(XTokenResponse):
|
|
46
|
+
display_claims: XAUDisplayClaims
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class XSTSDisplayClaims(BaseModel):
|
|
50
|
+
xui: list[dict[str, str]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class XSTSResponse(XTokenResponse):
|
|
54
|
+
display_claims: XSTSDisplayClaims
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def xuid(self) -> str:
|
|
58
|
+
return self.display_claims.xui[0]["xid"]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def userhash(self) -> str:
|
|
62
|
+
return self.display_claims.xui[0]["uhs"]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def gamertag(self) -> str:
|
|
66
|
+
return self.display_claims.xui[0]["gtg"]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def age_group(self) -> str:
|
|
70
|
+
return self.display_claims.xui[0]["agg"]
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def privileges(self) -> str:
|
|
74
|
+
return self.display_claims.xui[0]["prv"]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def user_privileges(self) -> str:
|
|
78
|
+
return self.display_claims.xui[0]["usr"]
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def authorization_header_value(self) -> str:
|
|
82
|
+
return f"XBL3.0 x={self.userhash};{self.token}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class OAuth2TokenResponse(BaseModel):
|
|
86
|
+
token_type: str
|
|
87
|
+
expires_in: int
|
|
88
|
+
scope: str
|
|
89
|
+
access_token: str
|
|
90
|
+
refresh_token: str | None = None
|
|
91
|
+
user_id: str
|
|
92
|
+
issued: datetime = Field(default_factory=utc_now)
|
|
93
|
+
|
|
94
|
+
def is_valid(self) -> bool:
|
|
95
|
+
return (self.issued + timedelta(seconds=self.expires_in)) > utc_now()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
"""XAL related models"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class XalAppParameters:
|
|
103
|
+
app_id: str
|
|
104
|
+
title_id: str
|
|
105
|
+
redirect_uri: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class XalClientParameters:
|
|
110
|
+
user_agent: str
|
|
111
|
+
device_type: str
|
|
112
|
+
client_version: str
|
|
113
|
+
query_display: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SisuAuthenticationResponse(PascalCaseModel):
|
|
117
|
+
msa_oauth_redirect: str
|
|
118
|
+
msa_request_parameters: dict[str, str]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SisuAuthorizationResponse(PascalCaseModel):
|
|
122
|
+
device_token: str
|
|
123
|
+
title_token: XATResponse
|
|
124
|
+
user_token: XAUResponse
|
|
125
|
+
authorization_token: XSTSResponse
|
|
126
|
+
web_page: str
|
|
127
|
+
sandbox: str
|
|
128
|
+
use_modern_gamertag: bool | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
"""Signature related models"""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TitleEndpoint(PascalCaseModel):
|
|
135
|
+
protocol: str
|
|
136
|
+
host: str
|
|
137
|
+
host_type: str
|
|
138
|
+
path: str | None = None
|
|
139
|
+
relying_party: str | None = None
|
|
140
|
+
sub_relying_party: str | None = None
|
|
141
|
+
token_type: str | None = None
|
|
142
|
+
signature_policy_index: int | None = None
|
|
143
|
+
server_cert_index: list[int] | None = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SignaturePolicy(PascalCaseModel):
|
|
147
|
+
version: int
|
|
148
|
+
supported_algorithms: list[str]
|
|
149
|
+
max_body_bytes: int
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TitleEndpointCertificate(PascalCaseModel):
|
|
153
|
+
thumbprint: str
|
|
154
|
+
is_issuer: bool | None = None
|
|
155
|
+
root_cert_index: int
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TitleEndpointsResponse(PascalCaseModel):
|
|
159
|
+
end_points: list[TitleEndpoint]
|
|
160
|
+
signature_policies: list[SignaturePolicy]
|
|
161
|
+
certs: list[TitleEndpointCertificate]
|
|
162
|
+
root_certs: list[str]
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Xbox Authentication Library
|
|
3
|
+
|
|
4
|
+
Authenticate with Windows Live Server and Xbox Live (used by mobile Xbox Apps)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from urllib import parse
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from ms_cv import CorrelationVector
|
|
17
|
+
|
|
18
|
+
from pythonxbox.authentication.models import (
|
|
19
|
+
OAuth2TokenResponse,
|
|
20
|
+
SisuAuthenticationResponse,
|
|
21
|
+
SisuAuthorizationResponse,
|
|
22
|
+
TitleEndpointsResponse,
|
|
23
|
+
XADResponse,
|
|
24
|
+
XalAppParameters,
|
|
25
|
+
XalClientParameters,
|
|
26
|
+
XSTSResponse,
|
|
27
|
+
)
|
|
28
|
+
from pythonxbox.common.signed_session import SignedSession
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("xal.authentication")
|
|
31
|
+
|
|
32
|
+
APP_PARAMS_XBOX_BETA_APP = XalAppParameters(
|
|
33
|
+
app_id="000000004415494b",
|
|
34
|
+
title_id="177887386",
|
|
35
|
+
redirect_uri="ms-xal-000000004415494b://auth",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
APP_PARAMS_XBOX_APP = XalAppParameters(
|
|
39
|
+
app_id="000000004c12ae6f",
|
|
40
|
+
title_id="328178078",
|
|
41
|
+
redirect_uri="ms-xal-000000004c12ae6f://auth",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
APP_PARAMS_GAMEPASS = XalAppParameters(
|
|
45
|
+
app_id="000000004c20a908",
|
|
46
|
+
title_id="1016898439",
|
|
47
|
+
redirect_uri="ms-xal-000000004c20a908://auth",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
APP_PARAMS_GAMEPASS_BETA = XalAppParameters(
|
|
51
|
+
app_id="000000004c20a908",
|
|
52
|
+
title_id="1016898439",
|
|
53
|
+
redirect_uri="ms-xal-public-beta-000000004c20a908://auth",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
APP_PARAMS_FAMILY_SETTINGS = XalAppParameters(
|
|
57
|
+
app_id="00000000482C8F49",
|
|
58
|
+
title_id="1618633878",
|
|
59
|
+
redirect_uri="https://login.live.com/oauth20_desktop.srf",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
CLIENT_PARAMS_IOS = XalClientParameters(
|
|
63
|
+
user_agent="XAL iOS 2021.11.20211021.000",
|
|
64
|
+
device_type="iOS",
|
|
65
|
+
client_version="15.6.1",
|
|
66
|
+
query_display="ios_phone",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
CLIENT_PARAMS_ANDROID = XalClientParameters(
|
|
70
|
+
user_agent="XAL Android 2020.07.20200714.000",
|
|
71
|
+
device_type="Android",
|
|
72
|
+
client_version="8.0.0",
|
|
73
|
+
query_display="android_phone",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class XALManager:
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
session: SignedSession,
|
|
81
|
+
device_id: uuid.UUID,
|
|
82
|
+
app_params: XalAppParameters,
|
|
83
|
+
client_params: XalClientParameters,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.session = session
|
|
86
|
+
self.device_id = device_id
|
|
87
|
+
self.app_params = app_params
|
|
88
|
+
self.client_params = client_params
|
|
89
|
+
self.cv = CorrelationVector()
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _get_random_bytes(length: int) -> bytes:
|
|
93
|
+
return os.urandom(length)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _generate_code_verifier() -> str:
|
|
97
|
+
# https://tools.ietf.org/html/rfc7636
|
|
98
|
+
code_verifier = (
|
|
99
|
+
base64.urlsafe_b64encode(XALManager._get_random_bytes(32))
|
|
100
|
+
.decode()
|
|
101
|
+
.rstrip("=")
|
|
102
|
+
)
|
|
103
|
+
assert len(code_verifier) >= 43 and len(code_verifier) <= 128
|
|
104
|
+
|
|
105
|
+
return code_verifier
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _get_code_challenge_from_code_verifier(code_verifier: str) -> str:
|
|
109
|
+
code_challenge = hashlib.sha256(code_verifier.encode()).digest()
|
|
110
|
+
# Base64 urlsafe encoding WITH stripping trailing '='
|
|
111
|
+
code_challenge = base64.urlsafe_b64encode(code_challenge).decode().rstrip("=")
|
|
112
|
+
|
|
113
|
+
return code_challenge
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _generate_random_state() -> str:
|
|
117
|
+
state = str(uuid.uuid4()).encode()
|
|
118
|
+
# Base64 urlsafe encoding WITHOUT stripping trailing '='
|
|
119
|
+
return base64.b64encode(state).decode()
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
async def get_title_endpoints(session: httpx.AsyncClient) -> TitleEndpointsResponse:
|
|
123
|
+
url = "https://title.mgt.xboxlive.com/titles/default/endpoints"
|
|
124
|
+
headers = {"x-xbl-contract-version": "1"}
|
|
125
|
+
params = {"type": 1}
|
|
126
|
+
resp = await session.get(url, headers=headers, params=params)
|
|
127
|
+
resp.raise_for_status()
|
|
128
|
+
return TitleEndpointsResponse(**resp.json())
|
|
129
|
+
|
|
130
|
+
async def request_device_token(self) -> XADResponse:
|
|
131
|
+
# Proof of possession: https://tools.ietf.org/html/rfc7800
|
|
132
|
+
|
|
133
|
+
device_id = str(self.device_id)
|
|
134
|
+
|
|
135
|
+
if self.client_params.device_type.lower() == "android":
|
|
136
|
+
# {decf45e4-945d-4379-b708-d4ee92c12d99}
|
|
137
|
+
device_id = f"{{{device_id}}}"
|
|
138
|
+
else:
|
|
139
|
+
# iOSs
|
|
140
|
+
# DECF45E4-945D-4379-B708-D4EE92C12D99
|
|
141
|
+
device_id = device_id.upper()
|
|
142
|
+
|
|
143
|
+
url = "https://device.auth.xboxlive.com/device/authenticate"
|
|
144
|
+
headers = {"x-xbl-contract-version": "1", "MS-CV": self.cv.get_value()}
|
|
145
|
+
data = {
|
|
146
|
+
"RelyingParty": "http://auth.xboxlive.com",
|
|
147
|
+
"TokenType": "JWT",
|
|
148
|
+
"Properties": {
|
|
149
|
+
"AuthMethod": "ProofOfPossession",
|
|
150
|
+
"Id": device_id,
|
|
151
|
+
"DeviceType": self.client_params.device_type,
|
|
152
|
+
"Version": self.client_params.client_version,
|
|
153
|
+
"ProofKey": self.session.request_signer.proof_field,
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
resp = await self.session.send_signed("POST", url, headers=headers, json=data)
|
|
158
|
+
resp.raise_for_status()
|
|
159
|
+
return XADResponse(**resp.json())
|
|
160
|
+
|
|
161
|
+
async def __oauth20_token_endpoint(self, json_body: dict) -> httpx.Response:
|
|
162
|
+
url = "https://login.live.com/oauth20_token.srf"
|
|
163
|
+
headers = {"MS-CV": self.cv.increment()}
|
|
164
|
+
|
|
165
|
+
# NOTE: No signature necessary
|
|
166
|
+
return await self.session.post(url, headers=headers, data=json_body)
|
|
167
|
+
|
|
168
|
+
async def exchange_code_for_token(
|
|
169
|
+
self, authorization_code: str, code_verifier: str
|
|
170
|
+
) -> OAuth2TokenResponse:
|
|
171
|
+
post_body = {
|
|
172
|
+
"client_id": self.app_params.app_id,
|
|
173
|
+
"code": authorization_code,
|
|
174
|
+
"code_verifier": code_verifier,
|
|
175
|
+
"grant_type": "authorization_code",
|
|
176
|
+
"redirect_uri": self.app_params.redirect_uri,
|
|
177
|
+
"scope": "service::user.auth.xboxlive.com::MBI_SSL",
|
|
178
|
+
}
|
|
179
|
+
resp = await self.__oauth20_token_endpoint(post_body)
|
|
180
|
+
resp.raise_for_status()
|
|
181
|
+
return OAuth2TokenResponse(**resp.json())
|
|
182
|
+
|
|
183
|
+
async def refresh_token(self, refresh_token_jwt: str) -> httpx.Response:
|
|
184
|
+
post_body = {
|
|
185
|
+
"client_id": self.app_params.app_id,
|
|
186
|
+
"refresh_token": refresh_token_jwt,
|
|
187
|
+
"grant_type": "refresh_token",
|
|
188
|
+
"redirect_uri": self.app_params.redirect_uri,
|
|
189
|
+
"scope": "service::user.auth.xboxlive.com::MBI_SSL",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
resp = await self.__oauth20_token_endpoint(post_body)
|
|
193
|
+
resp.raise_for_status()
|
|
194
|
+
return resp
|
|
195
|
+
|
|
196
|
+
async def request_sisu_authentication(
|
|
197
|
+
self, device_token_jwt: str, code_challenge: str, state: str
|
|
198
|
+
) -> tuple[SisuAuthenticationResponse, str]:
|
|
199
|
+
"""
|
|
200
|
+
Request Sisu authentication URL
|
|
201
|
+
|
|
202
|
+
Response holds authentication URL that needs to be called by the user
|
|
203
|
+
in webbrowser
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Tuple of (authentication response, sisu session id)
|
|
207
|
+
"""
|
|
208
|
+
url = "https://sisu.xboxlive.com/authenticate"
|
|
209
|
+
headers = {"x-xbl-contract-version": "1", "MS-CV": self.cv.increment()}
|
|
210
|
+
post_body = {
|
|
211
|
+
"AppId": self.app_params.app_id,
|
|
212
|
+
"TitleId": self.app_params.title_id,
|
|
213
|
+
"RedirectUri": self.app_params.redirect_uri,
|
|
214
|
+
"DeviceToken": device_token_jwt,
|
|
215
|
+
"Sandbox": "RETAIL",
|
|
216
|
+
"TokenType": "code",
|
|
217
|
+
"Offers": ["service::user.auth.xboxlive.com::MBI_SSL"],
|
|
218
|
+
"Query": {
|
|
219
|
+
"display": self.client_params.query_display,
|
|
220
|
+
"code_challenge": code_challenge,
|
|
221
|
+
"code_challenge_method": "S256",
|
|
222
|
+
"state": state,
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
resp = await self.session.send_signed(
|
|
227
|
+
"POST", url, headers=headers, json=post_body
|
|
228
|
+
)
|
|
229
|
+
resp.raise_for_status()
|
|
230
|
+
return (
|
|
231
|
+
SisuAuthenticationResponse.model_validate_json(resp.content),
|
|
232
|
+
resp.headers["X-SessionId"],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def do_sisu_authorization(
|
|
236
|
+
self, sisu_session_id: str, access_token_jwt: str, device_token_jwt: str
|
|
237
|
+
) -> SisuAuthorizationResponse:
|
|
238
|
+
"""
|
|
239
|
+
Sisu authorization
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Response with device-/title-/user-tokens
|
|
243
|
+
"""
|
|
244
|
+
url = "https://sisu.xboxlive.com/authorize"
|
|
245
|
+
headers = {"MS-CV": self.cv.increment()}
|
|
246
|
+
post_body = {
|
|
247
|
+
"AccessToken": f"t={access_token_jwt}",
|
|
248
|
+
"AppId": self.app_params.app_id,
|
|
249
|
+
"DeviceToken": device_token_jwt,
|
|
250
|
+
"Sandbox": "RETAIL",
|
|
251
|
+
"SiteName": "user.auth.xboxlive.com",
|
|
252
|
+
"SessionId": sisu_session_id,
|
|
253
|
+
"ProofKey": self.session.request_signer.proof_field,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
resp = await self.session.send_signed(
|
|
257
|
+
"POST", url, headers=headers, json=post_body
|
|
258
|
+
)
|
|
259
|
+
resp.raise_for_status()
|
|
260
|
+
return SisuAuthorizationResponse(**resp.json())
|
|
261
|
+
|
|
262
|
+
async def xsts_authorization(
|
|
263
|
+
self,
|
|
264
|
+
device_token_jwt: str,
|
|
265
|
+
title_token_jwt: str,
|
|
266
|
+
user_token_jwt: str,
|
|
267
|
+
relying_party: str,
|
|
268
|
+
) -> XSTSResponse:
|
|
269
|
+
"""
|
|
270
|
+
Request additional XSTS tokens for specific relying parties
|
|
271
|
+
"""
|
|
272
|
+
url = "https://xsts.auth.xboxlive.com/xsts/authorize"
|
|
273
|
+
headers = {"x-xbl-contract-version": "1", "MS-CV": self.cv.increment()}
|
|
274
|
+
post_body = {
|
|
275
|
+
"RelyingParty": relying_party,
|
|
276
|
+
"TokenType": "JWT",
|
|
277
|
+
"Properties": {
|
|
278
|
+
"SandboxId": "RETAIL",
|
|
279
|
+
"DeviceToken": device_token_jwt,
|
|
280
|
+
"TitleToken": title_token_jwt,
|
|
281
|
+
"UserTokens": [user_token_jwt],
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
resp = await self.session.send_signed(
|
|
286
|
+
"POST", url, headers=headers, json=post_body
|
|
287
|
+
)
|
|
288
|
+
resp.raise_for_status()
|
|
289
|
+
return XSTSResponse(**resp.json())
|
|
290
|
+
|
|
291
|
+
async def auth_flow(
|
|
292
|
+
self, user_input_cb: Callable[[str], str]
|
|
293
|
+
) -> SisuAuthorizationResponse:
|
|
294
|
+
"""
|
|
295
|
+
Does the whole XAL/Sisu authentication flow
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
user_input_cb: User callback which takes args: (auth_url: str) and
|
|
299
|
+
returns the redirect URL (str)
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Sisu authorization response with all tokens
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
# Fetch device token
|
|
306
|
+
device_token_resp = await self.request_device_token()
|
|
307
|
+
|
|
308
|
+
# Generate states for OAUTH
|
|
309
|
+
code_verifier = self._generate_code_verifier()
|
|
310
|
+
code_challenge = self._get_code_challenge_from_code_verifier(code_verifier)
|
|
311
|
+
state = self._generate_random_state()
|
|
312
|
+
|
|
313
|
+
# Request Sisu authentication URL
|
|
314
|
+
(
|
|
315
|
+
sisu_authenticate_resp,
|
|
316
|
+
sisu_session_id,
|
|
317
|
+
) = await self.request_sisu_authentication(
|
|
318
|
+
device_token_resp.token, code_challenge, state
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Prompt user for redirect URI after auth
|
|
322
|
+
redirect_uri = user_input_cb(sisu_authenticate_resp.msa_oauth_redirect)
|
|
323
|
+
|
|
324
|
+
# Ensure redirect URI looks like expected
|
|
325
|
+
if not redirect_uri.startswith(self.app_params.redirect_uri):
|
|
326
|
+
raise Exception("Wrong data passed as redirect URI")
|
|
327
|
+
|
|
328
|
+
# Parse URL query
|
|
329
|
+
query_params = dict(parse.parse_qsl(parse.urlsplit(redirect_uri).query))
|
|
330
|
+
|
|
331
|
+
# Extract code and state
|
|
332
|
+
resp_authorization_code = query_params["code"]
|
|
333
|
+
resp_state = query_params["state"]
|
|
334
|
+
|
|
335
|
+
if resp_state != state:
|
|
336
|
+
raise Exception("Response with non-matching state received")
|
|
337
|
+
|
|
338
|
+
# Exchange authentication code for tokens
|
|
339
|
+
tokens = await self.exchange_code_for_token(
|
|
340
|
+
resp_authorization_code, code_verifier
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Do Sisu authorization
|
|
344
|
+
sisu_authorization = await self.do_sisu_authorization(
|
|
345
|
+
sisu_session_id, tokens.access_token, device_token_resp.token
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return sisu_authorization
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Special Exception subclasses
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from pythonxbox.common.ratelimits import RateLimit
|
|
7
|
+
from httpx import Response
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class XboxException(Exception):
|
|
11
|
+
"""Base exception for all Xbox exceptions to subclass"""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthenticationException(XboxException):
|
|
17
|
+
"""Raised when logging in fails, likely due to incorrect auth credentials"""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TwoFactorAuthRequired(XboxException):
|
|
23
|
+
def __init__(self, message: str, server_data: dict[str, Any]) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Raised when 2FA is required
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
message (str): Exception message
|
|
29
|
+
server_data (dict): Server data dict, extracted js object from windows live auth request
|
|
30
|
+
"""
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.server_data = server_data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InvalidRequest(XboxException):
|
|
36
|
+
def __init__(self, message: str, response: Response) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Raised when something is wrong with the request
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
message (str): error message returned by the server
|
|
42
|
+
response (requests.Response): Instance of :class:`requests.Response`
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
self.message = message
|
|
46
|
+
self.response = response
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NotFoundException(XboxException):
|
|
50
|
+
"""Any exception raised due to a resource being missing will subclass this"""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RateLimitExceededException(XboxException):
|
|
56
|
+
def __init__(self, message: str, rate_limit: RateLimit) -> None:
|
|
57
|
+
self.message = message
|
|
58
|
+
self.rate_limit = rate_limit
|
|
59
|
+
self.try_again_in = rate_limit.get_reset_after()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Copyright (c) 2009, David Buxton <david@gasmark6.com>
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
#
|
|
4
|
+
# Redistribution and use in source and binary forms, with or without
|
|
5
|
+
# modification, are permitted provided that the following conditions are
|
|
6
|
+
# met:
|
|
7
|
+
#
|
|
8
|
+
# * Redistributions of source code must retain the above copyright
|
|
9
|
+
# notice, this list of conditions and the following disclaimer.
|
|
10
|
+
# * Redistributions in binary form must reproduce the above copyright
|
|
11
|
+
# notice, this list of conditions and the following disclaimer in the
|
|
12
|
+
# documentation and/or other materials provided with the distribution.
|
|
13
|
+
#
|
|
14
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
|
15
|
+
# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
|
16
|
+
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
|
17
|
+
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
18
|
+
# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
19
|
+
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
|
|
20
|
+
# TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
21
|
+
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
22
|
+
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
23
|
+
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
24
|
+
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
25
|
+
"""Tools to convert between Python datetime instances and Microsoft times."""
|
|
26
|
+
|
|
27
|
+
from calendar import timegm
|
|
28
|
+
from datetime import datetime, timedelta, UTC
|
|
29
|
+
|
|
30
|
+
# http://support.microsoft.com/kb/167296
|
|
31
|
+
# How To Convert a UNIX time_t to a Win32 FILETIME or SYSTEMTIME
|
|
32
|
+
EPOCH_AS_FILETIME = 116444736000000000 # January 1, 1970 as MS file time
|
|
33
|
+
HUNDREDS_OF_NANOSECONDS = 10000000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
ZERO = timedelta(0)
|
|
37
|
+
HOUR = timedelta(hours=1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def dt_to_filetime(dt: datetime) -> int:
|
|
41
|
+
"""Converts a datetime to Microsoft filetime format. If the object is
|
|
42
|
+
time zone-naive, it is forced to UTC before conversion.
|
|
43
|
+
|
|
44
|
+
>>> "%.0f" % dt_to_filetime(datetime(2009, 7, 25, 23, 0))
|
|
45
|
+
'128930364000000000'
|
|
46
|
+
|
|
47
|
+
>>> "%.0f" % dt_to_filetime(datetime(1970, 1, 1, 0, 0, tzinfo=UTC))
|
|
48
|
+
'116444736000000000'
|
|
49
|
+
|
|
50
|
+
>>> "%.0f" % dt_to_filetime(datetime(1970, 1, 1, 0, 0))
|
|
51
|
+
'116444736000000000'
|
|
52
|
+
|
|
53
|
+
>>> dt_to_filetime(datetime(2009, 7, 25, 23, 0, 0, 100))
|
|
54
|
+
128930364000001000
|
|
55
|
+
"""
|
|
56
|
+
if (dt.tzinfo is None) or (dt.tzinfo.utcoffset(dt) is None):
|
|
57
|
+
dt = dt.replace(tzinfo=UTC)
|
|
58
|
+
ft = EPOCH_AS_FILETIME + (timegm(dt.timetuple()) * HUNDREDS_OF_NANOSECONDS)
|
|
59
|
+
return ft + (dt.microsecond * 10)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def filetime_to_dt(ft: int) -> datetime:
|
|
63
|
+
"""Converts a Microsoft filetime number to a Python datetime. The new
|
|
64
|
+
datetime object is time zone-naive but is equivalent to tzinfo=utc.
|
|
65
|
+
|
|
66
|
+
>>> filetime_to_dt(116444736000000000)
|
|
67
|
+
datetime.datetime(1970, 1, 1, 0, 0)
|
|
68
|
+
|
|
69
|
+
>>> filetime_to_dt(128930364000000000)
|
|
70
|
+
datetime.datetime(2009, 7, 25, 23, 0)
|
|
71
|
+
|
|
72
|
+
>>> filetime_to_dt(128930364000001000)
|
|
73
|
+
datetime.datetime(2009, 7, 25, 23, 0, 0, 100)
|
|
74
|
+
"""
|
|
75
|
+
# Get seconds and remainder in terms of Unix epoch
|
|
76
|
+
(s, ns100) = divmod(ft - EPOCH_AS_FILETIME, HUNDREDS_OF_NANOSECONDS)
|
|
77
|
+
# Convert to datetime object
|
|
78
|
+
dt = datetime.fromtimestamp(s, UTC)
|
|
79
|
+
# Add remainder in as microseconds. Python 3.2 requires an integer
|
|
80
|
+
dt = dt.replace(microsecond=(ns100 // 10))
|
|
81
|
+
return dt
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Base Models."""
|
|
2
|
+
|
|
3
|
+
from pydantic import ConfigDict, BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def to_pascal(string: str) -> str:
|
|
7
|
+
return "".join(word.capitalize() for word in string.split("_"))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_camel(string: str) -> str:
|
|
11
|
+
words = string.split("_")
|
|
12
|
+
return words[0] + "".join(word.capitalize() for word in words[1:])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_lower(string: str) -> str:
|
|
16
|
+
return string.replace("_", "")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PascalCaseModel(BaseModel):
|
|
20
|
+
model_config = ConfigDict(
|
|
21
|
+
arbitrary_types_allowed=True, populate_by_name=True, alias_generator=to_pascal
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CamelCaseModel(BaseModel):
|
|
26
|
+
model_config = ConfigDict(
|
|
27
|
+
arbitrary_types_allowed=True, populate_by_name=True, alias_generator=to_camel
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LowerCaseModel(BaseModel):
|
|
32
|
+
model_config = ConfigDict(
|
|
33
|
+
arbitrary_types_allowed=True, populate_by_name=True, alias_generator=to_lower
|
|
34
|
+
)
|