python-xbox 0.1.0rc0__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.
Files changed (63) hide show
  1. python_xbox-0.1.0rc0.dist-info/METADATA +213 -0
  2. python_xbox-0.1.0rc0.dist-info/RECORD +63 -0
  3. python_xbox-0.1.0rc0.dist-info/WHEEL +4 -0
  4. python_xbox-0.1.0rc0.dist-info/entry_points.txt +6 -0
  5. python_xbox-0.1.0rc0.dist-info/licenses/LICENSE +19 -0
  6. webapi/__init__.py +4 -0
  7. webapi/api/__init__.py +0 -0
  8. webapi/api/client.py +166 -0
  9. webapi/api/language.py +70 -0
  10. webapi/api/provider/__init__.py +0 -0
  11. webapi/api/provider/account/__init__.py +71 -0
  12. webapi/api/provider/account/models.py +11 -0
  13. webapi/api/provider/achievements/__init__.py +164 -0
  14. webapi/api/provider/achievements/models.py +133 -0
  15. webapi/api/provider/baseprovider.py +16 -0
  16. webapi/api/provider/catalog/__init__.py +88 -0
  17. webapi/api/provider/catalog/const.py +15 -0
  18. webapi/api/provider/catalog/models.py +428 -0
  19. webapi/api/provider/cqs/__init__.py +85 -0
  20. webapi/api/provider/cqs/models.py +59 -0
  21. webapi/api/provider/gameclips/__init__.py +167 -0
  22. webapi/api/provider/gameclips/models.py +59 -0
  23. webapi/api/provider/lists/__init__.py +71 -0
  24. webapi/api/provider/lists/models.py +35 -0
  25. webapi/api/provider/mediahub/__init__.py +64 -0
  26. webapi/api/provider/mediahub/models.py +84 -0
  27. webapi/api/provider/message/__init__.py +135 -0
  28. webapi/api/provider/message/models.py +96 -0
  29. webapi/api/provider/people/__init__.py +193 -0
  30. webapi/api/provider/people/models.py +252 -0
  31. webapi/api/provider/presence/__init__.py +112 -0
  32. webapi/api/provider/presence/models.py +54 -0
  33. webapi/api/provider/profile/__init__.py +142 -0
  34. webapi/api/provider/profile/models.py +48 -0
  35. webapi/api/provider/ratelimitedprovider.py +77 -0
  36. webapi/api/provider/screenshots/__init__.py +167 -0
  37. webapi/api/provider/screenshots/models.py +56 -0
  38. webapi/api/provider/smartglass/__init__.py +399 -0
  39. webapi/api/provider/smartglass/models.py +187 -0
  40. webapi/api/provider/titlehub/__init__.py +141 -0
  41. webapi/api/provider/titlehub/models.py +106 -0
  42. webapi/api/provider/usersearch/__init__.py +29 -0
  43. webapi/api/provider/usersearch/models.py +19 -0
  44. webapi/api/provider/userstats/__init__.py +166 -0
  45. webapi/api/provider/userstats/models.py +46 -0
  46. webapi/authentication/__init__.py +0 -0
  47. webapi/authentication/manager.py +162 -0
  48. webapi/authentication/models.py +163 -0
  49. webapi/authentication/xal.py +348 -0
  50. webapi/common/__init__.py +0 -0
  51. webapi/common/exceptions.py +57 -0
  52. webapi/common/filetimes.py +97 -0
  53. webapi/common/models.py +34 -0
  54. webapi/common/ratelimits/__init__.py +269 -0
  55. webapi/common/ratelimits/models.py +23 -0
  56. webapi/common/request_signer.py +189 -0
  57. webapi/common/signed_session.py +54 -0
  58. webapi/scripts/__init__.py +15 -0
  59. webapi/scripts/authenticate.py +159 -0
  60. webapi/scripts/change_gamertag.py +111 -0
  61. webapi/scripts/friends.py +80 -0
  62. webapi/scripts/search.py +43 -0
  63. webapi/scripts/xal.py +113 -0
@@ -0,0 +1,141 @@
1
+ """
2
+ Titlehub - Get Title history and info
3
+ """
4
+
5
+ from typing import List, Optional
6
+
7
+ from xbox.webapi.api.provider.baseprovider import BaseProvider
8
+ from xbox.webapi.api.provider.titlehub.models import TitleFields, TitleHubResponse
9
+
10
+
11
+ class TitlehubProvider(BaseProvider):
12
+ TITLEHUB_URL = "https://titlehub.xboxlive.com"
13
+ SEPARATOR = ","
14
+
15
+ def __init__(self, client):
16
+ """
17
+ Initialize Baseclass, set 'Accept-Language' header from client instance
18
+
19
+ Args:
20
+ client (:class:`XboxLiveClient`): Instance of client
21
+ """
22
+ super().__init__(client)
23
+ self._headers = {
24
+ "x-xbl-contract-version": "2",
25
+ "x-xbl-client-name": "XboxApp",
26
+ "x-xbl-client-type": "UWA",
27
+ "x-xbl-client-version": "39.39.22001.0",
28
+ "Accept-Language": self.client.language.locale,
29
+ }
30
+
31
+ async def get_title_history(
32
+ self,
33
+ xuid: str,
34
+ fields: Optional[List[TitleFields]] = None,
35
+ max_items: Optional[int] = 5,
36
+ **kwargs,
37
+ ) -> TitleHubResponse:
38
+ """
39
+ Get recently played titles
40
+
41
+ Args:
42
+ xuid: Xuid
43
+ fields: List of titlefield
44
+ max_items: Maximum items
45
+
46
+ Returns:
47
+ :class:`TitleHubResponse`: Title Hub Response
48
+ """
49
+ if not fields:
50
+ fields = [
51
+ TitleFields.ACHIEVEMENT,
52
+ TitleFields.IMAGE,
53
+ TitleFields.SERVICE_CONFIG_ID,
54
+ ]
55
+ fields = self.SEPARATOR.join(fields)
56
+
57
+ url = f"{self.TITLEHUB_URL}/users/xuid({xuid})/titles/titlehistory/decoration/{fields}"
58
+ params = {"maxItems": max_items}
59
+ resp = await self.client.session.get(
60
+ url, params=params, headers=self._headers, **kwargs
61
+ )
62
+ resp.raise_for_status()
63
+ return TitleHubResponse(**resp.json())
64
+
65
+ async def _get_title_info(
66
+ self, moniker: str, fields: Optional[List[TitleFields]] = None, **kwargs
67
+ ) -> TitleHubResponse:
68
+ if not fields:
69
+ fields = [
70
+ TitleFields.ACHIEVEMENT,
71
+ TitleFields.ALTERNATE_TITLE_ID,
72
+ TitleFields.DETAIL,
73
+ TitleFields.IMAGE,
74
+ TitleFields.SERVICE_CONFIG_ID,
75
+ ]
76
+ fields = self.SEPARATOR.join(fields)
77
+
78
+ url = f"{self.TITLEHUB_URL}/users/xuid({self.client.xuid})/titles/{moniker}/decoration/{fields}"
79
+ resp = await self.client.session.get(url, headers=self._headers, **kwargs)
80
+ resp.raise_for_status()
81
+ return TitleHubResponse(**resp.json())
82
+
83
+ async def get_title_info(
84
+ self, title_id: str, fields: Optional[List[TitleFields]] = None, **kwargs
85
+ ) -> TitleHubResponse:
86
+ """
87
+ Get info for specific title
88
+
89
+ Args:
90
+ title_id: Title Id
91
+ fields: List of title fields
92
+
93
+ Returns:
94
+ :class:`TitleHubResponse`: Title Hub Response
95
+ """
96
+ return await self._get_title_info(f"titleid({title_id})", fields, **kwargs)
97
+
98
+ async def get_title_info_by_pfn(
99
+ self, pfn: str, fields: Optional[List[TitleFields]] = None, **kwargs
100
+ ) -> TitleHubResponse:
101
+ """
102
+ Get info for specific title by PFN
103
+
104
+ Args:
105
+ pfn: Package family name
106
+ fields: List of title fields
107
+
108
+ Returns:
109
+ :class:`TitleHubResponse`: Title Hub Response
110
+ """
111
+ return await self._get_title_info(f"pfn({pfn})", fields, **kwargs)
112
+
113
+ async def get_titles_batch(
114
+ self, pfns: List[str], fields: Optional[List[TitleFields]] = None, **kwargs
115
+ ) -> TitleHubResponse:
116
+ """
117
+ Get Title info via PFN ids
118
+
119
+ Args:
120
+ pfns: List of Package family names (e.g. 'Microsoft.XboxApp_8wekyb3d8bbwe')
121
+ fields: List of title fields
122
+
123
+ Returns:
124
+ :class:`TitleHubResponse`: Title Hub Response
125
+ """
126
+ if not fields:
127
+ fields = [
128
+ TitleFields.ACHIEVEMENT,
129
+ TitleFields.DETAIL,
130
+ TitleFields.IMAGE,
131
+ TitleFields.SERVICE_CONFIG_ID,
132
+ ]
133
+ fields = self.SEPARATOR.join(fields)
134
+
135
+ url = self.TITLEHUB_URL + f"/titles/batch/decoration/{fields}"
136
+ post_data = {"pfns": pfns, "windowsPhoneProductIds": []}
137
+ resp = await self.client.session.post(
138
+ url, json=post_data, headers=self._headers, **kwargs
139
+ )
140
+ resp.raise_for_status()
141
+ return TitleHubResponse(**resp.json())
@@ -0,0 +1,106 @@
1
+ from datetime import datetime
2
+ from enum import Enum
3
+ from typing import Any, List, Optional
4
+
5
+ from xbox.webapi.common.models import CamelCaseModel, PascalCaseModel
6
+
7
+
8
+ class TitleFields(str, Enum):
9
+ SERVICE_CONFIG_ID = "scid"
10
+ ACHIEVEMENT = "achievement"
11
+ STATS = "stats"
12
+ GAME_PASS = "gamepass"
13
+ IMAGE = "image"
14
+ DETAIL = "detail"
15
+ FRIENDS_WHO_PLAYED = "friendswhoplayed"
16
+ ALTERNATE_TITLE_ID = "alternateTitleId"
17
+ PRODUCT_ID = "productId"
18
+ CONTENT_BOARD = "contentBoard"
19
+
20
+
21
+ class Achievement(CamelCaseModel):
22
+ current_achievements: int
23
+ total_achievements: int
24
+ current_gamerscore: int
25
+ total_gamerscore: int
26
+ progress_percentage: float
27
+ source_version: int
28
+
29
+
30
+ class Stats(CamelCaseModel):
31
+ source_version: int
32
+
33
+
34
+ class GamePass(CamelCaseModel):
35
+ is_game_pass: bool
36
+
37
+
38
+ class Image(CamelCaseModel):
39
+ url: str
40
+ type: str
41
+
42
+
43
+ class TitleHistory(CamelCaseModel):
44
+ last_time_played: datetime
45
+ visible: bool
46
+ can_hide: bool
47
+
48
+
49
+ class Attribute(CamelCaseModel):
50
+ applicable_platforms: Optional[List[str]] = None
51
+ maximum: Optional[int] = None
52
+ minimum: Optional[int] = None
53
+ name: str
54
+
55
+
56
+ class Availability(PascalCaseModel):
57
+ actions: List[str]
58
+ availability_id: str
59
+ platforms: List[str]
60
+ sku_id: str
61
+
62
+
63
+ class Detail(CamelCaseModel):
64
+ attributes: List[Attribute]
65
+ availabilities: List[Availability]
66
+ capabilities: List[str]
67
+ description: str
68
+ developer_name: str | None = None
69
+ genres: Optional[List[str]] = None
70
+ publisher_name: str
71
+ min_age: int | None = None
72
+ release_date: Optional[datetime] = None
73
+ short_description: Optional[str] = None
74
+ vui_display_name: Optional[str] = None
75
+ xbox_live_gold_required: bool
76
+
77
+
78
+ class Title(CamelCaseModel):
79
+ title_id: str
80
+ pfn: Optional[str] = None
81
+ bing_id: Optional[str] = None
82
+ service_config_id: Optional[str] = None
83
+ windows_phone_product_id: Optional[str] = None
84
+ name: str
85
+ type: str
86
+ devices: List[str]
87
+ display_image: str
88
+ media_item_type: str
89
+ modern_title_id: Optional[str] = None
90
+ is_bundle: bool
91
+ achievement: Optional[Achievement] = None
92
+ stats: Optional[Stats] = None
93
+ game_pass: Optional[GamePass] = None
94
+ images: Optional[List[Image]] = None
95
+ title_history: Optional[TitleHistory] = None
96
+ detail: Optional[Detail] = None
97
+ friends_who_played: Any = None
98
+ alternate_title_ids: Any = None
99
+ content_boards: Any = None
100
+ xbox_live_tier: Optional[str] = None
101
+ is_streamable: Optional[bool] = None
102
+
103
+
104
+ class TitleHubResponse(CamelCaseModel):
105
+ xuid: Optional[str] = None
106
+ titles: List[Title]
@@ -0,0 +1,29 @@
1
+ """
2
+ Usersearch - Search for gamertags / userprofiles
3
+ """
4
+
5
+ from xbox.webapi.api.provider.baseprovider import BaseProvider
6
+ from xbox.webapi.api.provider.usersearch.models import UserSearchResponse
7
+
8
+
9
+ class UserSearchProvider(BaseProvider):
10
+ USERSEARCH_URL = "https://usersearch.xboxlive.com"
11
+ HEADERS_USER_SEARCH = {"x-xbl-contract-version": "1"}
12
+
13
+ async def get_live_search(self, query: str, **kwargs) -> UserSearchResponse:
14
+ """
15
+ Get userprofiles for search query
16
+
17
+ Args:
18
+ query: Search query
19
+
20
+ Returns:
21
+ :class:`UserSearchResponse`: User Search Response
22
+ """
23
+ url = self.USERSEARCH_URL + "/suggest"
24
+ params = {"q": query}
25
+ resp = await self.client.session.get(
26
+ url, params=params, headers=self.HEADERS_USER_SEARCH, **kwargs
27
+ )
28
+ resp.raise_for_status()
29
+ return UserSearchResponse(**resp.json())
@@ -0,0 +1,19 @@
1
+ from typing import List
2
+
3
+ from xbox.webapi.common.models import CamelCaseModel
4
+
5
+
6
+ class UserDetail(CamelCaseModel):
7
+ id: str
8
+ gamertag: str
9
+ display_pic_uri: str
10
+ score: float
11
+
12
+
13
+ class UserResult(CamelCaseModel):
14
+ text: str
15
+ result: UserDetail
16
+
17
+
18
+ class UserSearchResponse(CamelCaseModel):
19
+ results: List[UserResult]
@@ -0,0 +1,166 @@
1
+ """
2
+ Userstats - Get game statistics
3
+ """
4
+
5
+ from typing import List, Optional
6
+
7
+ from xbox.webapi.api.provider.ratelimitedprovider import RateLimitedProvider
8
+ from xbox.webapi.api.provider.userstats.models import (
9
+ GeneralStatsField,
10
+ UserStatsResponse,
11
+ )
12
+
13
+
14
+ class UserStatsProvider(RateLimitedProvider):
15
+ USERSTATS_URL = "https://userstats.xboxlive.com"
16
+ HEADERS_USERSTATS = {"x-xbl-contract-version": "2"}
17
+ HEADERS_USERSTATS_WITH_METADATA = {"x-xbl-contract-version": "3"}
18
+ SEPERATOR = ","
19
+
20
+ # NOTE: Stats Read (userstats.xboxlive.com) and Stats Write (statswrite.xboxlive.com)
21
+ # Are mentioned as their own objects but their rate limits are the same and do not collide
22
+ # (Stats Read -> read rate limit, Stats Write -> write rate limit)
23
+ RATE_LIMITS = {"burst": 100, "sustain": 300}
24
+
25
+ async def get_stats(
26
+ self,
27
+ xuid: str,
28
+ service_config_id: str,
29
+ stats_fields: Optional[List[GeneralStatsField]] = None,
30
+ **kwargs,
31
+ ) -> UserStatsResponse:
32
+ """
33
+ Get userstats
34
+
35
+ Args:
36
+ xuid: Xbox User Id
37
+ service_config_id: Service Config Id of Game (scid)
38
+ stats_fields: List of stats fields to acquire
39
+
40
+ Returns:
41
+ :class:`UserStatsResponse`: User Stats Response
42
+ """
43
+ if not stats_fields:
44
+ stats_fields = [GeneralStatsField.MINUTES_PLAYED]
45
+ stats = self.SEPERATOR.join(stats_fields)
46
+
47
+ url = f"{self.USERSTATS_URL}/users/xuid({xuid})/scids/{service_config_id}/stats/{stats}"
48
+ resp = await self.client.session.get(
49
+ url,
50
+ headers=self.HEADERS_USERSTATS,
51
+ rate_limits=self.rate_limit_read,
52
+ **kwargs,
53
+ )
54
+ resp.raise_for_status()
55
+ return UserStatsResponse(**resp.json())
56
+
57
+ async def get_stats_with_metadata(
58
+ self,
59
+ xuid: str,
60
+ service_config_id: str,
61
+ stats_fields: Optional[List[GeneralStatsField]] = None,
62
+ **kwargs,
63
+ ) -> UserStatsResponse:
64
+ """
65
+ Get userstats including metadata for each stat (if available)
66
+
67
+ Args:
68
+ xuid: Xbox User Id
69
+ service_config_id: Service Config Id of Game (scid)
70
+ stats_fields: List of stats fields to acquire
71
+
72
+ Returns:
73
+ :class:`UserStatsResponse`: User Stats Response
74
+ """
75
+ if not stats_fields:
76
+ stats_fields = [GeneralStatsField.MINUTES_PLAYED]
77
+ stats = self.SEPERATOR.join(stats_fields)
78
+
79
+ url = f"{self.USERSTATS_URL}/users/xuid({xuid})/scids/{service_config_id}/stats/{stats}"
80
+ params = {"include": "valuemetadata"}
81
+ resp = await self.client.session.get(
82
+ url,
83
+ params=params,
84
+ headers=self.HEADERS_USERSTATS_WITH_METADATA,
85
+ rate_limits=self.rate_limit_read,
86
+ **kwargs,
87
+ )
88
+ resp.raise_for_status()
89
+ return UserStatsResponse(**resp.json())
90
+
91
+ async def get_stats_batch(
92
+ self,
93
+ xuids: List[str],
94
+ title_id: str,
95
+ stats_fields: Optional[List[GeneralStatsField]] = None,
96
+ **kwargs,
97
+ ) -> UserStatsResponse:
98
+ """
99
+ Get userstats in batch mode
100
+
101
+ Args:
102
+ xuids: List of XUIDs to get stats for
103
+ title_id: Game Title Id
104
+ stats_fields: List of stats fields to acquire
105
+
106
+ Returns:
107
+ :class:`UserStatsResponse`: User Stats Response
108
+ """
109
+ if not stats_fields:
110
+ stats_fields = [GeneralStatsField.MINUTES_PLAYED]
111
+
112
+ url = self.USERSTATS_URL + "/batch"
113
+ post_data = {
114
+ "arrangebyfield": "xuid",
115
+ "groups": [{"name": "Hero", "titleId": title_id}],
116
+ "stats": [dict(name=stat, titleId=title_id) for stat in stats_fields],
117
+ "xuids": xuids,
118
+ }
119
+ resp = await self.client.session.post(
120
+ url,
121
+ json=post_data,
122
+ headers=self.HEADERS_USERSTATS,
123
+ rate_limits=self.rate_limit_read,
124
+ **kwargs,
125
+ )
126
+ resp.raise_for_status()
127
+ return UserStatsResponse(**resp.json())
128
+
129
+ async def get_stats_batch_by_scid(
130
+ self,
131
+ xuids: List[str],
132
+ service_config_id: str,
133
+ stats_fields: Optional[List[GeneralStatsField]] = None,
134
+ **kwargs,
135
+ ) -> UserStatsResponse:
136
+ """
137
+ Get userstats in batch mode, via scid
138
+
139
+ Args:
140
+ xuids: List of XUIDs to get stats for
141
+ service_config_id: Service Config Id of Game (scid)
142
+ stats_fields: List of stats fields to acquire
143
+
144
+ Returns:
145
+ :class:`UserStatsResponse`: User Stats Response
146
+ """
147
+ if not stats_fields:
148
+ stats_fields = [GeneralStatsField.MINUTES_PLAYED]
149
+
150
+ url = self.USERSTATS_URL + "/batch"
151
+
152
+ post_data = {
153
+ "arrangebyfield": "xuid",
154
+ "groups": [{"name": "Hero", "scid": service_config_id}],
155
+ "stats": [dict(name=stat, scid=service_config_id) for stat in stats_fields],
156
+ "xuids": xuids,
157
+ }
158
+ resp = await self.client.session.post(
159
+ url,
160
+ json=post_data,
161
+ headers=self.HEADERS_USERSTATS,
162
+ rate_limits=self.rate_limit_read,
163
+ **kwargs,
164
+ )
165
+ resp.raise_for_status()
166
+ return UserStatsResponse(**resp.json())
@@ -0,0 +1,46 @@
1
+ from typing import List, Optional
2
+
3
+ from xbox.webapi.common.models import LowerCaseModel, PascalCaseModel
4
+
5
+
6
+ class GeneralStatsField:
7
+ MINUTES_PLAYED = "MinutesPlayed"
8
+
9
+
10
+ class GroupProperties(PascalCaseModel):
11
+ ordinal: Optional[str] = None
12
+ sort_order: Optional[str] = None
13
+ display_name: Optional[str] = None
14
+ display_format: Optional[str] = None
15
+ display_semantic: Optional[str] = None
16
+
17
+
18
+ class Properties(PascalCaseModel):
19
+ display_name: Optional[str] = None
20
+
21
+
22
+ class Stat(LowerCaseModel):
23
+ group_properties: Optional[GroupProperties] = None
24
+ xuid: str
25
+ scid: str
26
+ name: str
27
+ type: str
28
+ value: str
29
+ properties: Properties
30
+
31
+
32
+ class StatListsCollectionItem(LowerCaseModel):
33
+ arrange_by_field: str
34
+ arrange_by_field_id: str
35
+ stats: List[Stat]
36
+
37
+
38
+ class Group(LowerCaseModel):
39
+ name: str
40
+ title_id: Optional[str] = None
41
+ statlistscollection: List[StatListsCollectionItem]
42
+
43
+
44
+ class UserStatsResponse(LowerCaseModel):
45
+ groups: Optional[List[Group]] = None
46
+ statlistscollection: List[StatListsCollectionItem]
File without changes
@@ -0,0 +1,162 @@
1
+ """
2
+ Authentication Manager
3
+
4
+ Authenticate with Windows Live Server and Xbox Live.
5
+ """
6
+
7
+ import logging
8
+ from typing import List, Optional
9
+
10
+ import httpx
11
+
12
+ from xbox.webapi.authentication.models import (
13
+ OAuth2TokenResponse,
14
+ XAUResponse,
15
+ XSTSResponse,
16
+ )
17
+ from xbox.webapi.common.exceptions import AuthenticationException
18
+ from xbox.webapi.common.signed_session import SignedSession
19
+
20
+ log = logging.getLogger("authentication")
21
+
22
+ DEFAULT_SCOPES = ["Xboxlive.signin", "Xboxlive.offline_access"]
23
+
24
+
25
+ class AuthenticationManager:
26
+ def __init__(
27
+ self,
28
+ client_session: SignedSession,
29
+ client_id: str,
30
+ client_secret: str,
31
+ redirect_uri: str,
32
+ scopes: Optional[List[str]] = None,
33
+ ):
34
+ if not isinstance(client_session, (SignedSession, httpx.AsyncClient)):
35
+ raise DeprecationWarning(
36
+ """Xbox WebAPI changed to use SignedSession (wrapped httpx.AsyncClient).
37
+ Please check the documentation"""
38
+ )
39
+
40
+ self.session: SignedSession = client_session
41
+ self._client_id: str = client_id
42
+ self._client_secret: str = client_secret
43
+ self._redirect_uri: str = redirect_uri
44
+ self._scopes: List[str] = scopes or DEFAULT_SCOPES
45
+
46
+ self.oauth: OAuth2TokenResponse = None
47
+ self.user_token: XAUResponse = None
48
+ self.xsts_token: XSTSResponse = None
49
+
50
+ def generate_authorization_url(self, state: Optional[str] = None) -> str:
51
+ """Generate Windows Live Authorization URL."""
52
+ query_params = {
53
+ "client_id": self._client_id,
54
+ "response_type": "code",
55
+ "approval_prompt": "auto",
56
+ "scope": " ".join(self._scopes),
57
+ "redirect_uri": self._redirect_uri,
58
+ }
59
+
60
+ if state:
61
+ query_params["state"] = state
62
+
63
+ return str(
64
+ httpx.URL(
65
+ "https://login.live.com/oauth20_authorize.srf", params=query_params
66
+ )
67
+ )
68
+
69
+ async def request_tokens(self, authorization_code: str) -> None:
70
+ """Request all tokens."""
71
+ self.oauth = await self.request_oauth_token(authorization_code)
72
+ self.user_token = await self.request_user_token()
73
+ self.xsts_token = await self.request_xsts_token()
74
+
75
+ async def refresh_tokens(self) -> None:
76
+ """Refresh all tokens."""
77
+ if not (self.oauth and self.oauth.is_valid()):
78
+ self.oauth = await self.refresh_oauth_token()
79
+ if not (self.user_token and self.user_token.is_valid()):
80
+ self.user_token = await self.request_user_token()
81
+ if not (self.xsts_token and self.xsts_token.is_valid()):
82
+ self.xsts_token = await self.request_xsts_token()
83
+
84
+ async def request_oauth_token(self, authorization_code: str) -> OAuth2TokenResponse:
85
+ """Request OAuth2 token."""
86
+ return await self._oauth2_token_request(
87
+ {
88
+ "grant_type": "authorization_code",
89
+ "code": authorization_code,
90
+ "scope": " ".join(self._scopes),
91
+ "redirect_uri": self._redirect_uri,
92
+ }
93
+ )
94
+
95
+ async def refresh_oauth_token(self) -> OAuth2TokenResponse:
96
+ """Refresh OAuth2 token."""
97
+ return await self._oauth2_token_request(
98
+ {
99
+ "grant_type": "refresh_token",
100
+ "scope": " ".join(self._scopes),
101
+ "refresh_token": self.oauth.refresh_token,
102
+ }
103
+ )
104
+
105
+ async def _oauth2_token_request(self, data: dict) -> OAuth2TokenResponse:
106
+ """Execute token requests."""
107
+ data["client_id"] = self._client_id
108
+ if self._client_secret:
109
+ data["client_secret"] = self._client_secret
110
+ resp = await self.session.post(
111
+ "https://login.live.com/oauth20_token.srf", data=data
112
+ )
113
+ resp.raise_for_status()
114
+ return OAuth2TokenResponse(**resp.json())
115
+
116
+ async def request_user_token(
117
+ self,
118
+ relying_party: str = "http://auth.xboxlive.com",
119
+ use_compact_ticket: bool = False,
120
+ ) -> XAUResponse:
121
+ """Authenticate via access token and receive user token."""
122
+ url = "https://user.auth.xboxlive.com/user/authenticate"
123
+ headers = {"x-xbl-contract-version": "1"}
124
+ data = {
125
+ "RelyingParty": relying_party,
126
+ "TokenType": "JWT",
127
+ "Properties": {
128
+ "AuthMethod": "RPS",
129
+ "SiteName": "user.auth.xboxlive.com",
130
+ "RpsTicket": self.oauth.access_token
131
+ if use_compact_ticket
132
+ else f"d={self.oauth.access_token}",
133
+ },
134
+ }
135
+
136
+ resp = await self.session.post(url, json=data, headers=headers)
137
+ resp.raise_for_status()
138
+ return XAUResponse(**resp.json())
139
+
140
+ async def request_xsts_token(
141
+ self, relying_party: str = "http://xboxlive.com"
142
+ ) -> XSTSResponse:
143
+ """Authorize via user token and receive final X token."""
144
+ url = "https://xsts.auth.xboxlive.com/xsts/authorize"
145
+ headers = {"x-xbl-contract-version": "1"}
146
+ data = {
147
+ "RelyingParty": relying_party,
148
+ "TokenType": "JWT",
149
+ "Properties": {
150
+ "UserTokens": [self.user_token.token],
151
+ "SandboxId": "RETAIL",
152
+ },
153
+ }
154
+
155
+ resp = await self.session.post(url, json=data, headers=headers)
156
+ if resp.status_code == 401: # if unauthorized
157
+ print(
158
+ "Failed to authorize you! Your password or username may be wrong or you are trying to use child account (< 18 years old)"
159
+ )
160
+ raise AuthenticationException()
161
+ resp.raise_for_status()
162
+ return XSTSResponse(**resp.json())