Skip to content

Commit 2e43aca

Browse files
cyberjunkyclaude
andcommitted
fix: use iOS service URL in widget SSO flow to fix 401 token rejection
Since ~2026-06-01 Garmin's API tier rejects DI bearer tokens whose audience is sso.garmin.com/sso/embed (issued by the widget login strategy). Mobile-path tokens (audience: mobile.integration.garmin.com) are still accepted. Root cause: _widget_web_login used service=sso_embed in the SSO signin params, so the CAS ticket was scoped to that URL. The DI exchange also used service_url=sso_embed, minting a token with the wrong audience for connectapi.garmin.com. Fix: set service=_ios_service_url in the widget signin params and pass _ios_service_url to _establish_session, matching what the mobile strategies do. Also broadens the ticket extraction regex from the hardcoded embed? prefix to ?ticket=(ST-...) so it matches any service URL. Same fix applied to _complete_mfa_widget. Closes #369 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6e0cff7 commit 2e43aca

1 file changed

Lines changed: 17 additions & 8 deletions

File tree

garminconnect/client.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,9 @@ def __init__(self, domain: str = "garmin.com", **kwargs: Any) -> None:
159159
# Portal service URL is domain-aware for CN support
160160
# Mobile service URLs are domain-aware for CN support
161161
self._ios_service_url = f"https://mobile.integration.{domain}/gcm/ios"
162-
self._mobile_sso_service_url = f"https://mobile.integration.{domain}/gcm/android"
162+
self._mobile_sso_service_url = (
163+
f"https://mobile.integration.{domain}/gcm/android"
164+
)
163165
self._portal_service_url = f"https://connect.{domain}/app"
164166
# DI auth host is domain-aware too — CN users live on diauth.garmin.cn
165167
# and don't exist in the .com user database. Without this, token refresh
@@ -409,7 +411,9 @@ def _do_mobile_login(self, sess: Any, email: str, password: str) -> None:
409411

410412
if resp_type == "SUCCESSFUL":
411413
ticket = res["serviceTicketId"]
412-
self._establish_session(ticket, sess=sess, service_url=self._ios_service_url)
414+
self._establish_session(
415+
ticket, sess=sess, service_url=self._ios_service_url
416+
)
413417
return
414418

415419
if resp_type == "INVALID_USERNAME_PASSWORD":
@@ -443,10 +447,13 @@ def _widget_web_login(self, email: str, password: str) -> None:
443447
"embedWidget": "true",
444448
"gauthHost": sso_base,
445449
}
450+
# Use the iOS mobile service URL as the SSO service so the CAS ticket
451+
# is issued for mobile.integration.garmin.com. Since ~2026-06-01
452+
# Garmin's API tier rejects DI tokens whose audience is sso/embed.
446453
signin_params = {
447454
**embed_params,
448455
"gauthHost": sso_embed,
449-
"service": sso_embed,
456+
"service": self._ios_service_url,
450457
"source": sso_embed,
451458
"redirectAfterAccountLoginUrl": sso_embed,
452459
"redirectAfterAccountCreationUrl": sso_embed,
@@ -545,12 +552,14 @@ def _widget_web_login(self, email: str, password: str) -> None:
545552
f"Widget login: unexpected title '{title}'"
546553
)
547554

548-
# Step 4: Extract service ticket
549-
ticket_match = re.search(r'embed\?ticket=([^"]+)"', r.text)
555+
# Step 4: Extract service ticket — ticket may appear under any service URL
556+
ticket_match = re.search(r'\?ticket=(ST-[^"&\s]+)', r.text)
550557
if not ticket_match:
551558
raise GarminConnectConnectionError("Widget login: missing service ticket")
552559

553-
self._establish_session(ticket_match.group(1), sess=sess, service_url=sso_embed)
560+
self._establish_session(
561+
ticket_match.group(1), sess=sess, service_url=self._ios_service_url
562+
)
554563

555564
def _complete_mfa_widget(self, mfa_code: str) -> None:
556565
"""Complete MFA for widget flow."""
@@ -585,14 +594,14 @@ def _complete_mfa_widget(self, mfa_code: str) -> None:
585594
if title != "Success":
586595
raise GarminConnectAuthenticationError(f"Widget MFA failed: {title}")
587596

588-
ticket_match = re.search(r'embed\?ticket=([^"]+)"', r.text)
597+
ticket_match = re.search(r'\?ticket=(ST-[^"&\s]+)', r.text)
589598
if not ticket_match:
590599
raise GarminConnectAuthenticationError("Widget MFA: missing service ticket")
591600

592601
self._establish_session(
593602
ticket_match.group(1),
594603
sess=sess,
595-
service_url=f"{self._sso}/sso/embed",
604+
service_url=self._ios_service_url,
596605
)
597606

598607
# ------------------------------------------------------------------ #

0 commit comments

Comments
 (0)