Skip to content

Commit b3d7fd8

Browse files
authored
Merge pull request #371 from jedrazb/fix/email-mfa-widget-login
fix: detect email-based MFA in widget SSO login
2 parents 4028de8 + 22a9aa6 commit b3d7fd8

2 files changed

Lines changed: 98 additions & 1 deletion

File tree

garminconnect/client.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,13 @@ def _widget_web_login(self, email: str, password: str) -> None:
619619
f"Widget login: account restricted '{title}'"
620620
)
621621

622-
if "MFA" in title:
622+
# MFA challenge. The page title depends on the MFA method: authenticator
623+
# (TOTP) apps return "Enter MFA code for login", while email one-time
624+
# codes return "GARMIN Authentication Application". Both are completed
625+
# through the same verifyMFA endpoint, so detect either. Email MFA is
626+
# mandatory on ECG-capable devices (e.g. Venu 4, Fenix 8), and the
627+
# previous literal "MFA"-only check locked those accounts out entirely.
628+
if "mfa" in title_lower or "authentication application" in title_lower:
623629
self._mfa_session = sess
624630
self._mfa_login_params = signin_params
625631
self._mfa_post_headers = {"Referer": r.url}

tests/test_widget_mfa.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Tests for SSO widget-login MFA detection.
2+
3+
In-process, no network: a fake ``curl_cffi`` session feeds the widget login
4+
strategy canned HTML so we can assert how each post-login page title is
5+
handled. Covers both MFA variants and guards against over-broad detection.
6+
"""
7+
8+
import contextlib
9+
from types import SimpleNamespace
10+
from unittest.mock import patch
11+
12+
import pytest
13+
14+
from garminconnect import client as client_mod
15+
from garminconnect.client import _MFARequired
16+
from garminconnect.exceptions import GarminConnectConnectionError
17+
18+
# The signin GET must yield a CSRF token for the flow to reach the POST.
19+
_CSRF_HTML = '<input name="_csrf" value="tok123"/>'
20+
21+
22+
def _resp(text="", status_code=200, url="https://sso.garmin.com/sso/signin"):
23+
return SimpleNamespace(
24+
text=text,
25+
status_code=status_code,
26+
url=url,
27+
ok=200 <= status_code < 400,
28+
)
29+
30+
31+
def _page(title):
32+
return f"<html><head><title>{title}</title></head><body></body></html>"
33+
34+
35+
class _FakeSession:
36+
"""Minimal stand-in for curl_cffi's Session for the widget flow.
37+
38+
Every GET returns a CSRF-bearing page (covers both the embed and signin
39+
GETs); the credential POST returns the caller-supplied page carrying the
40+
title under test.
41+
"""
42+
43+
def __init__(self, post_text):
44+
self._post_text = post_text
45+
46+
def get(self, url, **kwargs):
47+
return _resp(text=_CSRF_HTML)
48+
49+
def post(self, url, **kwargs):
50+
return _resp(text=self._post_text)
51+
52+
53+
@contextlib.contextmanager
54+
def _widget_session(post_text):
55+
"""Patch the widget flow to drive ``_FakeSession`` with no real network/delay."""
56+
with (
57+
patch.object(client_mod, "HAS_CFFI", True),
58+
patch.object(
59+
client_mod,
60+
"cffi_requests",
61+
SimpleNamespace(Session=lambda *a, **k: _FakeSession(post_text)),
62+
),
63+
patch.object(client_mod.time, "sleep"),
64+
):
65+
yield
66+
67+
68+
@pytest.mark.parametrize(
69+
"title",
70+
[
71+
"GARMIN Authentication Application", # email one-time-code MFA
72+
"Enter MFA code for login", # authenticator-app (TOTP) MFA
73+
],
74+
)
75+
def test_mfa_titles_trigger_mfa(title):
76+
"""Both MFA page-title variants must enter the MFA completion flow."""
77+
c = client_mod.Client()
78+
with _widget_session(_page(title)), pytest.raises(_MFARequired):
79+
c._widget_web_login("e@x.com", "pw")
80+
81+
assert c._mfa_flow == "widget"
82+
assert c._widget_last_resp is not None
83+
84+
85+
def test_unexpected_title_still_errors():
86+
"""A genuinely unknown page must NOT be misread as an MFA challenge."""
87+
c = client_mod.Client()
88+
with _widget_session(_page("Some Unrelated Page")), pytest.raises(
89+
GarminConnectConnectionError, match="unexpected title"
90+
):
91+
c._widget_web_login("e@x.com", "pw")

0 commit comments

Comments
 (0)