toqla_auth.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. #!/usr/bin/env python3
  2. """
  3. Shared authentication helpers for the Toqla/Sodexo API.
  4. Handles OAuth2 PKCE (Proof Key for Code Exchange) with Auth0 session cookies
  5. to silently obtain an access token — no browser interaction needed.
  6. Provide Auth0 session cookies via:
  7. --cookies "did=...; auth0=..."
  8. or COOKIES env var, or cookies.txt file.
  9. To get your cookies:
  10. 1. Open https://app.toqla.fr and log in.
  11. 2. DevTools (F12) > Network > any request to myid.sodexo.com
  12. 3. Copy the "Cookie" header value (did=...; auth0=...)
  13. The refresh_token obtained from Auth0 is saved to refresh_token.txt and reused
  14. on subsequent runs (no cookies needed) until it expires. This module is shared
  15. by fetch_menus.py and fetch_tickets.py so both scripts reuse the same cached
  16. refresh token / cookies file.
  17. """
  18. import base64
  19. import hashlib
  20. import logging
  21. import os
  22. import re
  23. import secrets
  24. import sys
  25. from pathlib import Path
  26. from urllib.parse import urlparse, parse_qs, unquote
  27. import requests
  28. # ---------------------------------------------------------------------------
  29. # Constants
  30. # ---------------------------------------------------------------------------
  31. BASE_URL = "https://zeus.helium.sodexo.com"
  32. API_KEY = "94770481-bd9c-4013-96c9-9b85d0759ee2"
  33. AUTH0_DOMAIN = "https://myid.sodexo.com"
  34. AUTH0_CLIENT_ID = "cnEsGucenOXKbw8XBJpKhitMB0JUiojz"
  35. AUTH0_REDIRECT_URI = "https://app.toqla.fr/oidc/callback"
  36. AUTH0_AUDIENCE = "https://api.pluxee.app/fr/consumer_prd/consumer-bff/api/"
  37. AUTH0_SCOPE = (
  38. "openid offline_access profile email phone "
  39. "https://api.pluxee.app/fr/consumer_prd/consumer-bff/api/scopes/consumer:bff"
  40. )
  41. REFRESH_TOKEN_FILE = "refresh_token.txt"
  42. COOKIES_FILE = "cookies.txt"
  43. log = logging.getLogger(__name__)
  44. _COMMON_HEADERS = {
  45. "accept": "*/*",
  46. "content-type": "application/x-www-form-urlencoded",
  47. "origin": "https://app.toqla.fr",
  48. "referer": "https://app.toqla.fr/",
  49. "auth0-client": "eyJuYW1lIjoiYXV0aDAtcmVhY3QiLCJ2ZXJzaW9uIjoiMi4xNi4yIn0=",
  50. "user-agent": (
  51. "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
  52. "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
  53. ),
  54. }
  55. # ---------------------------------------------------------------------------
  56. # Cookie handling
  57. # ---------------------------------------------------------------------------
  58. def parse_cookie_string(cookie_str: str) -> dict:
  59. """Parse a cookie header string into a dict {name: value}."""
  60. cookies = {}
  61. for part in cookie_str.split(";"):
  62. part = part.strip()
  63. if "=" in part:
  64. k, v = part.split("=", 1)
  65. cookies[k.strip()] = v.strip()
  66. return cookies
  67. def load_cookies(args_cookies: str | None, cookies_file: str = COOKIES_FILE) -> dict:
  68. """Load cookies from args, env var, or a cookies file."""
  69. # Priority: --cookies arg > COOKIES env > cookies.txt file
  70. raw = args_cookies or os.environ.get("COOKIES")
  71. if raw:
  72. return parse_cookie_string(raw)
  73. try:
  74. raw = Path(cookies_file).read_text(encoding="utf-8").strip()
  75. if raw:
  76. log.info("Loaded cookies from %s.", cookies_file)
  77. return parse_cookie_string(raw)
  78. except FileNotFoundError:
  79. pass
  80. return {}
  81. # ---------------------------------------------------------------------------
  82. # PKCE
  83. # ---------------------------------------------------------------------------
  84. def generate_pkce_pair() -> tuple[str, str]:
  85. """Generate a PKCE code_verifier and code_challenge (S256)."""
  86. verifier = secrets.token_urlsafe(64)[:128]
  87. digest = hashlib.sha256(verifier.encode("ascii")).digest()
  88. challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
  89. return verifier, challenge
  90. # ---------------------------------------------------------------------------
  91. # Token acquisition
  92. # ---------------------------------------------------------------------------
  93. def silent_auth_with_cookies(cookies: dict) -> str:
  94. """Use Auth0 session cookies + PKCE to silently obtain an access token.
  95. Flow (same as the browser does with prompt=none):
  96. 1. Generate PKCE code_verifier + code_challenge
  97. 2. GET /authorize with cookies + PKCE (response_mode=web_message)
  98. 3. Extract authorization code from the HTML response
  99. 4. POST /oauth/token with grant_type=authorization_code + code_verifier
  100. Returns the access_token string.
  101. Writes the new refresh token back to refresh_token.txt.
  102. """
  103. if not cookies:
  104. log.error(
  105. "No Auth0 session cookies available. Provide them via "
  106. "--cookies, COOKIES env var, or cookies.txt file."
  107. )
  108. sys.exit(1)
  109. verifier, challenge = generate_pkce_pair()
  110. state = secrets.token_urlsafe(16)
  111. nonce = secrets.token_urlsafe(16)
  112. # Step 1: GET /authorize with cookies (silent, prompt=none)
  113. auth_url = (
  114. f"{AUTH0_DOMAIN}/authorize"
  115. f"?client_id={AUTH0_CLIENT_ID}"
  116. f"&scope={AUTH0_SCOPE.replace(' ', '+')}"
  117. f"&audience={AUTH0_AUDIENCE}"
  118. f"&redirect_uri={AUTH0_REDIRECT_URI}"
  119. f"&response_type=code"
  120. f"&prompt=none"
  121. f"&response_mode=web_message"
  122. f"&state={state}"
  123. f"&nonce={nonce}"
  124. f"&code_challenge={challenge}"
  125. f"&code_challenge_method=S256"
  126. f"&auth0Client=eyJuYW1lIjoiYXV0aDAtcmVhY3QiLCJ2ZXJzaW9uIjoiMi4xNi4yIn0%3D"
  127. )
  128. log.info("Silent authorization via Auth0 with session cookies…")
  129. resp = requests.get(
  130. auth_url,
  131. headers={
  132. "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
  133. "accept-language": "en-GB,en;q=0.9,en-US;q=0.8,fr;q=0.7",
  134. "origin": "https://app.toqla.fr",
  135. "referer": "https://app.toqla.fr/",
  136. "auth0-client": "eyJuYW1lIjoiYXV0aDAtcmVhY3QiLCJ2ZXJzaW9uIjoiMi4xNi4yIn0=",
  137. "user-agent": _COMMON_HEADERS["user-agent"],
  138. },
  139. cookies=cookies,
  140. timeout=30,
  141. allow_redirects=True,
  142. )
  143. resp.raise_for_status()
  144. # Step 2: Extract the authorization code from the HTML response
  145. # Auth0 returns an HTML page with the code in a <script> tag:
  146. # window.parent.postMessage({type: "authorization_response", response: {code: "...", ...}}, "*")
  147. # or in an <input name="code" value="...">
  148. html = resp.text
  149. code = None
  150. # Try to find code in postMessage JSON
  151. m = re.search(r'"code"\s*:\s*"([^"]+)"', html)
  152. if m:
  153. code = m.group(1)
  154. # Fallback: look for input name="code"
  155. if not code:
  156. m = re.search(r'<input[^>]*name="code"[^>]*value="([^"]+)"', html)
  157. if m:
  158. code = m.group(1)
  159. # Fallback: look for code in URL fragment (response_mode=web_message uses hash)
  160. if not code and "#" in resp.url:
  161. fragment = urlparse(resp.url).fragment
  162. params = parse_qs(fragment)
  163. if "code" in params:
  164. code = params["code"][0]
  165. if not code:
  166. # Maybe Auth0 returned an error
  167. error_m = re.search(r'"error"\s*:\s*"([^"]+)"', html)
  168. error_desc_m = re.search(r'"error_description"\s*:\s*"([^"]+)"', html)
  169. if error_m:
  170. error = error_m.group(1)
  171. desc = error_desc_m.group(1) if error_desc_m else ""
  172. log.error("Auth0 error: %s — %s", error, unquote(desc.replace("+", " ")))
  173. log.error(
  174. "Could not extract authorization code from Auth0 response. "
  175. "Your cookies may have expired. Response length: %d chars.",
  176. len(html),
  177. )
  178. sys.exit(1)
  179. log.info("Authorization code obtained. Exchanging for tokens…")
  180. # Step 3: POST /oauth/token with authorization_code + code_verifier
  181. token_resp = requests.post(
  182. f"{AUTH0_DOMAIN}/oauth/token",
  183. headers=_COMMON_HEADERS,
  184. data={
  185. "client_id": AUTH0_CLIENT_ID,
  186. "redirect_uri": AUTH0_REDIRECT_URI,
  187. "response_type": "code",
  188. "code_verifier": verifier,
  189. "code": code,
  190. "grant_type": "authorization_code",
  191. },
  192. timeout=30,
  193. )
  194. token_resp.raise_for_status()
  195. body = token_resp.json()
  196. access_token = body.get("access_token")
  197. new_refresh = body.get("refresh_token")
  198. if not access_token:
  199. log.error("No access_token in Auth0 response: %s", body)
  200. sys.exit(1)
  201. if new_refresh:
  202. Path(REFRESH_TOKEN_FILE).write_text(new_refresh + "\n", encoding="utf-8")
  203. log.info("New refresh token saved to %s.", REFRESH_TOKEN_FILE)
  204. return access_token
  205. def refresh_access_token(cookies: dict | None = None) -> str:
  206. """Get a fresh access token.
  207. Strategy:
  208. 1. Try refresh_token.txt (no cookies needed).
  209. 2. If it fails or there's no refresh_token, use PKCE + Auth0 session cookies.
  210. 3. If no cookies either, fail with clear instructions.
  211. Returns the access_token string on success.
  212. Writes the new refresh token back to refresh_token.txt.
  213. """
  214. # Try refresh_token first
  215. try:
  216. rt = Path(REFRESH_TOKEN_FILE).read_text(encoding="utf-8").strip()
  217. except FileNotFoundError:
  218. rt = ""
  219. if rt:
  220. log.info("Trying refresh token from %s…", REFRESH_TOKEN_FILE)
  221. resp = requests.post(
  222. f"{AUTH0_DOMAIN}/oauth/token",
  223. headers=_COMMON_HEADERS,
  224. data={
  225. "client_id": AUTH0_CLIENT_ID,
  226. "redirect_uri": AUTH0_REDIRECT_URI,
  227. "response_type": "code",
  228. "grant_type": "refresh_token",
  229. "refresh_token": rt,
  230. },
  231. timeout=30,
  232. )
  233. if resp.status_code == 200:
  234. body = resp.json()
  235. access_token = body.get("access_token")
  236. new_refresh = body.get("refresh_token")
  237. if access_token:
  238. if new_refresh:
  239. Path(REFRESH_TOKEN_FILE).write_text(new_refresh + "\n", encoding="utf-8")
  240. log.info("New refresh token saved to %s.", REFRESH_TOKEN_FILE)
  241. return access_token
  242. log.warning("Refresh token failed (HTTP %d) — trying cookie-based auth.", resp.status_code)
  243. # Fallback: PKCE with cookies
  244. if cookies:
  245. return silent_auth_with_cookies(cookies)
  246. log.error(
  247. "No valid refresh token and no cookies available.\n"
  248. "Provide Auth0 session cookies via --cookies, COOKIES env var, or cookies.txt.\n"
  249. "To get cookies: open https://app.toqla.fr, DevTools > Network > copy Cookie header."
  250. )
  251. sys.exit(1)
  252. # ---------------------------------------------------------------------------
  253. # Session
  254. # ---------------------------------------------------------------------------
  255. def build_session(token: str) -> requests.Session:
  256. """Build a requests.Session pre-configured with the Toqla API auth headers."""
  257. session = requests.Session()
  258. session.headers.update({
  259. "Authorization": f"Bearer {token}",
  260. "x-api-key": API_KEY,
  261. "Accept": "application/json",
  262. "Origin": "https://app.toqla.fr",
  263. "Referer": "https://app.toqla.fr/",
  264. "User-Agent": _COMMON_HEADERS["user-agent"],
  265. })
  266. return session