Skip to content

Commit 827cd70

Browse files
committed
[WIP] Implement mobile auth flow (from #7)
1 parent 546b54c commit 827cd70

File tree

9 files changed

+269
-20
lines changed

9 files changed

+269
-20
lines changed

podme_api/auth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from .client import PodMeAuthClient, PodMeDefaultAuthClient
2+
from .mobile_client import PodMeMobileAuthClient
23
from .models import PodMeUserCredentials, SchibstedCredentials
34

45
__all__ = [
56
"PodMeAuthClient",
67
"PodMeDefaultAuthClient",
78
"PodMeUserCredentials",
9+
"PodMeMobileAuthClient",
810
"SchibstedCredentials",
911
]

podme_api/auth/client.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,14 @@ async def authorize(self, user_credentials: PodMeUserCredentials) -> SchibstedCr
276276
"redirectToAccountPage": "",
277277
},
278278
)
279-
final_location = response.history[-1].headers.get("Location")
280-
jwt_cookie = response.history[-1].cookies.get("jwt-cred").value
281-
jwt_cred = unquote(jwt_cookie)
282-
self.set_credentials(jwt_cred)
283-
284-
_LOGGER.debug(f"Login successful: (final location: {final_location})")
279+
jwt_cred = next(
280+
(unquote(h.cookies["jwt-cred"].value) for h in response.history if "jwt-cred" in h.cookies), None
281+
)
282+
if jwt_cred:
283+
self.set_credentials(jwt_cred)
284+
_LOGGER.debug("Login successful")
285+
else:
286+
_LOGGER.error("Login failed")
285287

286288
await self.close()
287289

podme_api/auth/common.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ def invalidate_credentials(self):
7373
"""Invalidate the current credentials."""
7474
raise NotImplementedError # pragma: no cover
7575

76+
@property
77+
def credentials_filename(self):
78+
"""Get the filename for storing credentials."""
79+
return "credentials.json"
80+
7681
async def close(self) -> None:
7782
"""Close open client session."""
7883
if self.session and self._close_session:

podme_api/auth/mobile_client.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from datetime import datetime, timezone
5+
import hashlib
6+
import json
7+
import logging
8+
import os
9+
import secrets
10+
from typing import TYPE_CHECKING
11+
12+
from aiohttp.hdrs import METH_GET, METH_POST
13+
import pkce
14+
from yarl import URL
15+
16+
from podme_api.auth.client import PodMeDefaultAuthClient
17+
from podme_api.auth.models import SchibstedCredentials
18+
19+
if TYPE_CHECKING:
20+
from podme_api.auth.models import PodMeUserCredentials
21+
22+
_LOGGER = logging.getLogger(__name__)
23+
24+
CLIENT_ID = "62557b19f552881812b7431c"
25+
26+
27+
@dataclass
28+
class PodMeMobileAuthClient(PodMeDefaultAuthClient):
29+
"""Default authentication client for PodMe.
30+
31+
This class handles authentication using Schibsted credentials for the PodMe service.
32+
"""
33+
34+
device_data = {
35+
"platform": "Android",
36+
"userAgent": "Chrome",
37+
"userAgentVersion": "128.0.0.0",
38+
"hasLiedOs": "0",
39+
"hasLiedBrowser": "0",
40+
"fonts": [
41+
"Arial",
42+
"Courier",
43+
"Courier New",
44+
"Georgia",
45+
"Helvetica",
46+
"Monaco",
47+
"Palatino",
48+
"Tahoma",
49+
"Times",
50+
"Times New Roman",
51+
"Verdana",
52+
],
53+
"plugins": [],
54+
}
55+
"""Device information for authentication."""
56+
57+
async def authorize(self, user_credentials: PodMeUserCredentials) -> SchibstedCredentials:
58+
code_verifier, code_challenge = pkce.generate_pkce_pair()
59+
response = await self._request(
60+
"oauth/authorize",
61+
params={
62+
"client_id": CLIENT_ID,
63+
"redirect_uri": f"pme.podme.{CLIENT_ID}:/login",
64+
"response_type": "code",
65+
"scope": "openid offline_access",
66+
"state": hashlib.sha256(os.urandom(1024)).hexdigest(),
67+
"nonce": secrets.token_urlsafe(),
68+
"code_challenge": code_challenge,
69+
"code_challenge_method": "S256",
70+
"prompt": "select_account",
71+
},
72+
allow_redirects=False,
73+
)
74+
# Login: step 1/3
75+
await self._request("", METH_GET, response.headers.get("Location"))
76+
# Login: step 2/4
77+
response = await self._request(
78+
"authn/api/settings/csrf",
79+
params={"client_id": CLIENT_ID},
80+
)
81+
csrf_token = (await response.json())["data"]["attributes"]["csrfToken"]
82+
83+
# Login: step 3/4
84+
response = await self._request(
85+
"authn/api/identity/email-status",
86+
method=METH_POST,
87+
params={"client_id": CLIENT_ID},
88+
headers={
89+
"X-CSRF-Token": csrf_token,
90+
"Accept": "application/json",
91+
},
92+
data={
93+
"email": user_credentials.email,
94+
"deviceData": json.dumps(self.device_data),
95+
},
96+
)
97+
email_status = await response.json()
98+
_LOGGER.debug(f"Email status: {email_status}")
99+
100+
# Login: step 4/4
101+
response = await self._request(
102+
"authn/api/identity/login/",
103+
method=METH_POST,
104+
params={"client_id": CLIENT_ID},
105+
headers={
106+
"X-CSRF-Token": csrf_token,
107+
"Accept": "application/json",
108+
},
109+
data={
110+
"username": user_credentials.email,
111+
"password": user_credentials.password,
112+
"remember": "true",
113+
"deviceData": json.dumps(self.device_data),
114+
},
115+
)
116+
login_response = await response.json()
117+
_LOGGER.debug(f"Login response: {login_response}")
118+
119+
# Finalize login
120+
response = await self._request(
121+
"authn/identity/finish/",
122+
method=METH_POST,
123+
params={"client_id": CLIENT_ID},
124+
headers={
125+
"Content-Type": "application/x-www-form-urlencoded",
126+
},
127+
data={
128+
"deviceData": json.dumps(self.device_data),
129+
"remember": "true",
130+
"_csrf": csrf_token,
131+
"redirectToAccountPage": "",
132+
},
133+
allow_redirects=False,
134+
)
135+
136+
# Follow redirect manually
137+
response = await self._request("", METH_GET, response.headers.get("Location"), allow_redirects=False)
138+
code = URL(response.headers.get("Location")).query.get("code")
139+
140+
# Request tokens with authorization code
141+
response = await self._request(
142+
"oauth/token",
143+
method=METH_POST,
144+
headers={
145+
"X-OIDC": "v1",
146+
"X-Region": "NO", # @TODO: Support multiple regions.
147+
},
148+
data={
149+
"client_id": CLIENT_ID,
150+
"grant_type": "authorization_code",
151+
"code": code,
152+
"redirect_uri": f"pme.podme.{CLIENT_ID}:/login",
153+
"code_verifier": code_verifier,
154+
},
155+
allow_redirects=False,
156+
)
157+
158+
jwt_cred = await response.json()
159+
jwt_cred["expiration_time"] = int(datetime.now(tz=timezone.utc).timestamp() + jwt_cred["expires_in"])
160+
self.set_credentials(jwt_cred)
161+
162+
_LOGGER.debug("Login successful")
163+
164+
await self.close()
165+
166+
return self._credentials
167+
168+
async def refresh_token(self, credentials: SchibstedCredentials | None = None):
169+
if credentials is None:
170+
credentials = self._credentials
171+
172+
response = await self._request(
173+
"oauth/token",
174+
method=METH_POST,
175+
headers={
176+
"Host": "payment.schibsted.no",
177+
"Content-Type": "application/x-www-form-urlencoded",
178+
"User-Agent": "AccountSDKAndroidWeb/6.4.0 (Linux; Android 15; API 35; Google; sdk_gphone64_arm64)",
179+
"X-OIDC": "v1",
180+
"X-Region": "NO", # @TODO: Support multiple regions.
181+
},
182+
data={
183+
"client_id": CLIENT_ID,
184+
"grant_type": "refresh_token",
185+
"refresh_token": credentials.refresh_token,
186+
},
187+
allow_redirects=False,
188+
)
189+
190+
refreshed_credentials = await response.json()
191+
refreshed_credentials["expiration_time"] = int(
192+
datetime.now(tz=timezone.utc).timestamp() + refreshed_credentials["expires_in"]
193+
)
194+
self.set_credentials(
195+
SchibstedCredentials.from_dict(
196+
{
197+
**credentials.to_dict(),
198+
**refreshed_credentials,
199+
}
200+
)
201+
)
202+
203+
_LOGGER.debug(f"Refreshed credentials: {self.get_credentials()}")
204+
205+
await self.close()
206+
207+
return self._credentials
208+
209+
def credentials_filename(self):
210+
return "credentials_mobile.json"

podme_api/client.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import math
1212
from pathlib import Path
1313
import socket
14-
from typing import TYPE_CHECKING, Callable, Self, Sequence, TypeVar
14+
from typing import TYPE_CHECKING, Any, Callable, Self, Sequence, TypeVar
1515

1616
import aiofiles
1717
import aiofiles.os
@@ -25,6 +25,7 @@
2525
from podme_api.const import (
2626
DEFAULT_REQUEST_TIMEOUT,
2727
PODME_API_URL,
28+
PODME_API_USER_AGENT,
2829
)
2930
from podme_api.exceptions import (
3031
PodMeApiConnectionError,
@@ -39,6 +40,7 @@
3940
PodMeApiUnauthorizedError,
4041
)
4142
from podme_api.models import (
43+
PodMeApiPlatform,
4244
PodMeCategory,
4345
PodMeCategoryPage,
4446
PodMeDownloadProgressTask,
@@ -74,6 +76,9 @@ class PodMeClient:
7476
auth_client: PodMeAuthClient
7577
"""auth_client (PodMeAuthClient): The authentication client."""
7678

79+
api_platform: PodMeApiPlatform = PodMeApiPlatform.MOBILE
80+
"""api_platform (PodMeApiPlatform): The API platform to use."""
81+
7782
disable_credentials_storage: bool = False
7883
"""Whether to disable credential storage."""
7984

@@ -114,7 +119,7 @@ async def save_credentials(self, filename: PathLike | None = None) -> None:
114119
115120
"""
116121
if filename is None:
117-
filename = Path(self._conf_dir) / "credentials.json"
122+
filename = Path(self._conf_dir) / self.auth_client.credentials_filename
118123
filename = Path(filename).resolve()
119124
credentials = self.auth_client.get_credentials()
120125
if credentials is None: # pragma: no cover
@@ -132,7 +137,7 @@ async def load_credentials(self, filename: PathLike | None = None) -> None:
132137
133138
"""
134139
if filename is None:
135-
filename = Path(self._conf_dir) / "credentials.json"
140+
filename = Path(self._conf_dir) / self.auth_client.credentials_filename
136141
filename = Path(filename).resolve()
137142
if not filename.exists():
138143
_LOGGER.warning("Credentials file does not exist: <%s>", filename)
@@ -171,7 +176,8 @@ async def _request( # noqa: C901
171176
The response data from the API.
172177
173178
"""
174-
url = URL(f"{PODME_API_URL.strip('/')}/").join(URL(uri))
179+
base_url = PODME_API_URL.format(platform=self.api_platform)
180+
url = URL(f"{base_url.strip('/')}/").join(URL(uri))
175181

176182
access_token = await self.auth_client.async_get_access_token()
177183
headers = {
@@ -264,6 +270,7 @@ def request_header(self) -> dict[str, str]:
264270
return {
265271
"Accept": "application/json",
266272
"X-Region": str(self.region),
273+
"User-Agent": PODME_API_USER_AGENT,
267274
}
268275

269276
async def _get_pages(
@@ -983,7 +990,7 @@ async def _resolve_m3u8_url(self, master_url: URL | str) -> FetchedFileInfo:
983990
# Parse master.m3u8 to get the audio playlist URL (first match only).
984991
audio_playlist_url: URL | None = None
985992
for line in master_content.splitlines():
986-
if line.endswith(".m3u8"):
993+
if ".m3u8" in line:
987994
audio_playlist_url = master_url.join(URL(line.strip()))
988995
break
989996

@@ -1013,7 +1020,7 @@ async def _resolve_m3u8_url(self, master_url: URL | str) -> FetchedFileInfo:
10131020
@staticmethod
10141021
async def _run_concurrent(
10151022
func: Callable[..., T],
1016-
args_list: Sequence[any],
1023+
args_list: Sequence[Any],
10171024
**kwargs: any,
10181025
) -> list[T]:
10191026
"""Run multiple asynchronous tasks concurrently.

podme_api/const.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import logging
44

55
PODME_AUTH_USER_AGENT = "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0"
6-
PODME_API_URL = "https://api.podme.com/web/api/v2"
6+
PODME_API_USER_AGENT = "Podme android app/6.29.3 (Linux;Android 15) AndroidXMedia3/1.5.1"
7+
PODME_API_URL = "https://api.podme.com/{platform}/api/v2"
78
PODME_BASE_URL = "https://podme.com"
89
PODME_AUTH_BASE_URL = "https://payment.schibsted.no"
910
PODME_AUTH_RETURN_URL = f"{PODME_BASE_URL}/no/oppdag"

podme_api/models.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ def __repr__(self):
4646
return self.value.lower()
4747

4848

49+
class PodMeApiPlatform(StrEnum):
50+
"""Enumeration of PodMe API platforms."""
51+
52+
WEB = auto()
53+
MOBILE = auto()
54+
55+
4956
class PodMeRegion(IntEnum):
5057
"""Enumeration of PodMe regions."""
5158

@@ -338,17 +345,20 @@ class PodMeEpisode(PodMeEpisodeBase):
338345
medium_image_url: str = field(metadata=field_options(alias="mediumImageUrl"))
339346
stream_url: str | None = field(default=None, metadata=field_options(alias="streamUrl"))
340347
slug: str | None = None
341-
current_spot: time = field(
348+
current_spot: time | None = field(
349+
default=None,
342350
metadata=field_options(
343351
alias="currentSpot",
344352
deserialize=time.fromisoformat,
345353
serialize=time.isoformat,
346-
)
354+
),
355+
)
356+
current_spot_sec: int | None = field(default=None, metadata=field_options(alias="currentSpotSec"))
357+
episode_can_be_played: bool | None = field(
358+
default=None, metadata=field_options(alias="episodeCanBePlayed")
347359
)
348-
current_spot_sec: int = field(metadata=field_options(alias="currentSpotSec"))
349-
episode_can_be_played: bool = field(metadata=field_options(alias="episodeCanBePlayed"))
350360
only_as_package_subscription: bool = field(metadata=field_options(alias="onlyAsPackageSubscription"))
351-
has_completed: bool = field(metadata=field_options(alias="hasCompleted"))
361+
has_completed: bool | None = field(default=None, metadata=field_options(alias="hasCompleted"))
352362
is_rss: bool | None = field(default=None, metadata=field_options(alias="isRss"))
353363
total_no_of_episodes: int | None = field(default=None, metadata=field_options(alias="totalNoOfEpisodes"))
354364

0 commit comments

Comments
 (0)