Skip to content

fix: route DI token endpoint to garmin.cn for CN accounts#360

Merged
cyberjunky merged 1 commit into
cyberjunky:masterfrom
MidnightV1:fix/cn-di-token-url
May 2, 2026
Merged

fix: route DI token endpoint to garmin.cn for CN accounts#360
cyberjunky merged 1 commit into
cyberjunky:masterfrom
MidnightV1:fix/cn-di-token-url

Conversation

@MidnightV1
Copy link
Copy Markdown

@MidnightV1 MidnightV1 commented Apr 26, 2026

Bug

is_cn=True correctly routes the SSO and Connect API endpoints via the domain-aware _sso / _connect / _connectapi attributes, but the DI OAuth2 token endpoint (DI_TOKEN_URL) stays hardcoded to diauth.garmin.com. CN users don't exist in the .com user database, so:

  • Initial token exchange (post-SSO) succeeds for fresh logins — the .com DI endpoint accepts the ticket and issues a token whose JWT payload still carries iss=https://diauth.garmin.cn (the issuer claim correctly reflects the CN auth server, but the token was minted by .com).
  • Token refresh fails with HTTP 400:
    {"error":"invalid_grant","error_description":"User doesn't exist in DB for GarminGUID: <uuid>"}
    
    because the refresh path looks up the user by GUID in the host's DB, and CN users only exist on diauth.garmin.cn.

This matters in practice: the access token's TTL is ~4 hours, so any CN account that stays logged in longer than a single session will hit the refresh path and break. Cron-driven sync jobs are the canonical case.

Reproduce

from garminconnect import Garmin
g = Garmin("[email protected]", "password", is_cn=True)
g.login()
# Wait > 4 hours (or manually call g.client._refresh_di_token())
g.get_user_summary("2026-04-26")
# → GarminConnectAuthenticationError: DI token refresh failed: 400
#    {"error":"invalid_grant","error_description":"User doesn't exist in DB for GarminGUID: ..."}

Fix

Store _di_token_url as an instance attribute built from domain (same pattern as _sso / _connect / _connectapi), and use it in _exchange_ticket() and _refresh_di_token(). The module-level DI_TOKEN_URL constant is kept for backwards compatibility with external imports.

After fix: refresh hits https://diauth.garmin.cn/di-oauth2-service/oauth/token → 200.

Verification

Manually patched the same code locally (monkey-patching garminconnect.client.DI_TOKEN_URL to the CN variant before calling _refresh_di_token()) — refresh succeeds, subsequent connectapi() calls work, persisted tokens reload cleanly across processes. Verified against a real garmin.cn account.

Notes

  • DI_GRANT_TYPE is left as the .com URI because it's a brand-namespace OAuth2 grant_type identifier (RFC 6749), not an HTTP endpoint.
  • This fix is minimal and local — no behavior changes for garmin.com users.

Summary by CodeRabbit

  • Bug Fixes
    • Enabled OAuth token refresh for non-.com domains through region-specific authentication support

`is_cn=True` correctly routes the SSO and Connect API endpoints via the
domain-aware `_sso` / `_connect` / `_connectapi` attributes, but the DI
OAuth2 token endpoint stayed hardcoded to `diauth.garmin.com`. CN users
don't exist in the .com user database, so:

- Token exchange (post-SSO) succeeded for fresh logins because the SSO
  ticket validates server-side regardless of which DI auth host receives
  it (the .com endpoint accepts the ticket and issues a token whose JWT
  payload still carries `iss=https://diauth.garmin.cn` — confusing!)
- BUT token refresh fails with 400:
  `{"error":"invalid_grant","error_description":"User doesn't exist in
  DB for GarminGUID: <uuid>"}`
  because the refresh path looks up the user by GUID in the host's DB.

This matters in practice: the access token's TTL is ~4 hours, so any CN
account that stays logged in longer than a single session will hit the
refresh path and break.

Fix: store `_di_token_url` as an instance attribute built from `domain`
(same pattern as `_sso` / `_connect`), and use it in `_exchange_ticket()`
and `_refresh_di_token()`. The module-level `DI_TOKEN_URL` constant is
kept for backwards compatibility with external imports.

Reproduce: `Garmin(email, password, is_cn=True).login()` then wait
4+ hours, then make any API call → 401 → 400 invalid_grant on refresh.

After fix: token refresh hits `https://diauth.garmin.cn/...` → 200.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 26, 2026

Walkthrough

The OAuth token exchange and refresh logic in the Garmin Connect client now uses a domain-specific DI auth host URL computed during initialization, replacing a fixed constant. This enables token refresh for different domain environments (e.g., CN) that use diauth.{domain}.

Changes

Cohort / File(s) Summary
OAuth Token Handling
garminconnect/client.py
Modified _exchange_service_ticket() and _refresh_di_token() to use domain-specific self._di_token_url instance attribute instead of fixed DI_TOKEN_URL constant. The URL is now computed in __init__ based on the provided domain parameter.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: routing the DI token endpoint to garmin.cn for CN accounts, which is the core bug fix in this pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
garminconnect/client.py (2)

894-898: ⚠️ Potential issue | 🟡 Minor

Stale docstring references diauth.garmin.com.

Now that the endpoint is domain-aware via self._di_token_url, the docstring claim "POST to diauth.garmin.com" is misleading for CN users — it's the one place a future reader would look to confirm the host is dynamic.

📝 Suggested doc tweak
         """Exchange a CAS service ticket for native DI + IT Bearer tokens.
 
-        POST to diauth.garmin.com to get a DI OAuth2 token, then exchange
-        for an IT token via services.garmin.com.
+        POST to ``self._di_token_url`` (``diauth.{domain}``) to get a DI
+        OAuth2 token, then exchange for an IT token via services.{domain}.
         """
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@garminconnect/client.py` around lines 894 - 898, The docstring for the method
that exchanges a CAS service ticket (the function which posts to obtain DI/IT
tokens and uses self._di_token_url) incorrectly hardcodes "POST to
diauth.garmin.com"; update the docstring to remove the static hostname and
instead describe that the code POSTs to the domain-aware DI token endpoint
referenced by self._di_token_url (or simply "the DI token endpoint"), and
mention that the host is determined by self._di_token_url so it works for CN or
other domains.

956-980: 🧹 Nitpick | 🔵 Trivial

Refresh path correctly uses the instance URL — verify CN refresh under load.

_refresh_di_token now POSTs to self._di_token_url, which directly addresses the reported invalid_grant "User doesn't exist in DB for GarminGUID" failure on CN. The PR notes manual verification with a garmin.cn account.

One thing worth noting (not blocking): on a refresh failure, _refresh_session (line 1028) only logs at debug level and swallows the exception, so a regression here would be silent — the next API call would just continue with the (possibly stale) di_token until it 401s and triggers a re-refresh loop. Consider whether a CN-domain integration test or at least a unit test asserting self._di_token_url is the POST target on domain="garmin.cn" would be worth adding to lock this down.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@garminconnect/client.py` around lines 956 - 980, Add a unit test that
verifies _refresh_di_token posts to the instance-specific URL by instantiating
the client with domain="garmin.cn" (or mocking instance resolution), mocking the
_http_post method and asserting it is called with self._di_token_url as the URL
and with the expected headers/data; specifically target the _refresh_di_token
method and mock _http_post so the test fails if code still posts to a hardcoded
CN endpoint. Optionally, add a small test that exercises _refresh_session to
confirm failures are at least logged (mocking _refresh_di_token to raise) so
regressions that silently swallow DI refresh errors are detected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@garminconnect/client.py`:
- Around line 894-898: The docstring for the method that exchanges a CAS service
ticket (the function which posts to obtain DI/IT tokens and uses
self._di_token_url) incorrectly hardcodes "POST to diauth.garmin.com"; update
the docstring to remove the static hostname and instead describe that the code
POSTs to the domain-aware DI token endpoint referenced by self._di_token_url (or
simply "the DI token endpoint"), and mention that the host is determined by
self._di_token_url so it works for CN or other domains.
- Around line 956-980: Add a unit test that verifies _refresh_di_token posts to
the instance-specific URL by instantiating the client with domain="garmin.cn"
(or mocking instance resolution), mocking the _http_post method and asserting it
is called with self._di_token_url as the URL and with the expected headers/data;
specifically target the _refresh_di_token method and mock _http_post so the test
fails if code still posts to a hardcoded CN endpoint. Optionally, add a small
test that exercises _refresh_session to confirm failures are at least logged
(mocking _refresh_di_token to raise) so regressions that silently swallow DI
refresh errors are detected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 703aa15d-b166-4170-ae85-90b4ded40d2f

📥 Commits

Reviewing files that changed from the base of the PR and between a95a5c3 and 78565a4.

📒 Files selected for processing (1)
  • garminconnect/client.py

@cyberjunky
Copy link
Copy Markdown
Owner

Thanks a lot!

@cyberjunky cyberjunky merged commit 94ff883 into cyberjunky:master May 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants