Here's what was built and why each decision was made:
Key at data_dir.parent/.garmin_key — nginx serves location /data/ { alias /var/bincio/data/; } so
anything inside that dir is reachable. The key lives one level up at /var/bincio/.garmin_key,
outside nginx's reach.
Two-layer storage — garmin_creds.json holds the encrypted email+password (needed for re-login when
tokens expire); garmin_session/ holds the garth OAuth tokens in plain JSON (short-lived, not the
user's actual password).
test_login() — called by the connect endpoint before saving anything, so credentials are only
persisted if they actually work.
get_client() — tries the session first (fast, no network), falls back to full re-login
transparently. The caller never needs to think about whether the session is fresh.
This commit is contained in:
@@ -11,6 +11,7 @@ build/
|
||||
htmlcov/
|
||||
.coverage
|
||||
.idea*
|
||||
feedback*
|
||||
|
||||
# uv
|
||||
uv.lock
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Garmin Connect credential storage and client factory.
|
||||
|
||||
Credential storage layout
|
||||
─────────────────────────
|
||||
{data_dir.parent}/.garmin_key ← Fernet key (outside nginx webroot, chmod 600)
|
||||
{user_dir}/garmin_creds.json ← encrypted email + password
|
||||
{user_dir}/garmin_session/ ← garth OAuth token directory (plain JSON, short-lived)
|
||||
|
||||
Security model
|
||||
──────────────
|
||||
- The Fernet key lives one directory above the data root, which nginx does NOT serve.
|
||||
For a standard VPS install: data_dir = /var/bincio/data/ → key at /var/bincio/.garmin_key.
|
||||
- Credentials are encrypted with that key before being written to disk.
|
||||
- The garth session directory holds OAuth tokens (not the user's password).
|
||||
These expire independently and are refreshed automatically by the library.
|
||||
- If the session expires and re-login is needed, the stored credentials are decrypted
|
||||
and used automatically — the user does not need to re-enter them.
|
||||
|
||||
DISCLAIMER
|
||||
──────────
|
||||
This module uses the unofficial `garminconnect` library.
|
||||
See docs/garmin_connect_disclaimer.md before shipping this feature to users.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_CREDS_FILE = "garmin_creds.json"
|
||||
_SESSION_DIR = "garmin_session"
|
||||
_KEY_FILENAME = ".garmin_key"
|
||||
|
||||
|
||||
class GarminError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# ── Encryption key management ─────────────────────────────────────────────────
|
||||
|
||||
def _key_path(data_dir: Path) -> Path:
|
||||
"""Return the path to the Fernet key file (one level above data_dir)."""
|
||||
return data_dir.parent / _KEY_FILENAME
|
||||
|
||||
|
||||
def _get_or_create_key(data_dir: Path) -> bytes:
|
||||
"""Load the Fernet key, creating and locking it down on first use."""
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
kp = _key_path(data_dir)
|
||||
if kp.exists():
|
||||
return kp.read_bytes().strip()
|
||||
|
||||
key = Fernet.generate_key()
|
||||
kp.parent.mkdir(parents=True, exist_ok=True)
|
||||
kp.write_bytes(key)
|
||||
kp.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600 — owner read/write only
|
||||
return key
|
||||
|
||||
|
||||
def _fernet(data_dir: Path):
|
||||
from cryptography.fernet import Fernet
|
||||
return Fernet(_get_or_create_key(data_dir))
|
||||
|
||||
|
||||
# ── Credential encryption helpers ─────────────────────────────────────────────
|
||||
|
||||
def _encrypt(data_dir: Path, value: str) -> str:
|
||||
return _fernet(data_dir).encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def _decrypt(data_dir: Path, token: str) -> str:
|
||||
try:
|
||||
return _fernet(data_dir).decrypt(token.encode()).decode()
|
||||
except Exception as exc:
|
||||
raise GarminError("Failed to decrypt Garmin credentials — key may have changed") from exc
|
||||
|
||||
|
||||
# ── Credential CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
def has_credentials(user_dir: Path) -> bool:
|
||||
return (user_dir / _CREDS_FILE).exists()
|
||||
|
||||
|
||||
def save_credentials(data_dir: Path, user_dir: Path, email: str, password: str) -> None:
|
||||
"""Encrypt and persist the user's Garmin email + password."""
|
||||
payload = {
|
||||
"email": _encrypt(data_dir, email),
|
||||
"password": _encrypt(data_dir, password),
|
||||
}
|
||||
creds_path = user_dir / _CREDS_FILE
|
||||
creds_path.write_text(json.dumps(payload, indent=2))
|
||||
creds_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
|
||||
|
||||
|
||||
def load_credentials(data_dir: Path, user_dir: Path) -> tuple[str, str]:
|
||||
"""Return (email, password) decrypted from disk."""
|
||||
creds_path = user_dir / _CREDS_FILE
|
||||
if not creds_path.exists():
|
||||
raise GarminError("No Garmin credentials stored for this user")
|
||||
try:
|
||||
raw = json.loads(creds_path.read_text())
|
||||
except Exception as exc:
|
||||
raise GarminError("Garmin credentials file is corrupt") from exc
|
||||
return _decrypt(data_dir, raw["email"]), _decrypt(data_dir, raw["password"])
|
||||
|
||||
|
||||
def delete_credentials(user_dir: Path) -> None:
|
||||
"""Remove stored credentials and session (disconnect)."""
|
||||
creds_path = user_dir / _CREDS_FILE
|
||||
if creds_path.exists():
|
||||
creds_path.unlink()
|
||||
|
||||
session_dir = user_dir / _SESSION_DIR
|
||||
if session_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(session_dir)
|
||||
|
||||
|
||||
# ── Session management (garth OAuth tokens) ───────────────────────────────────
|
||||
|
||||
def _session_dir(user_dir: Path) -> Path:
|
||||
d = user_dir / _SESSION_DIR
|
||||
d.mkdir(exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _save_session(user_dir: Path, client) -> None:
|
||||
"""Persist garth OAuth tokens so the next sync skips re-login."""
|
||||
try:
|
||||
client.garth.dump(str(_session_dir(user_dir)))
|
||||
except Exception:
|
||||
pass # session save is best-effort
|
||||
|
||||
|
||||
def _load_session(user_dir: Path, client) -> bool:
|
||||
"""Try to restore a saved garth session. Returns True on success."""
|
||||
sd = user_dir / _SESSION_DIR
|
||||
if not sd.exists():
|
||||
return False
|
||||
try:
|
||||
client.garth.load(str(sd))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── Client factory ────────────────────────────────────────────────────────────
|
||||
|
||||
def get_client(data_dir: Path, user_dir: Path):
|
||||
"""Return a logged-in Garmin client.
|
||||
|
||||
Strategy:
|
||||
1. Try to resume a saved garth session (fast, no network round-trip).
|
||||
2. If that fails or the session has expired, re-login using the stored
|
||||
(encrypted) credentials.
|
||||
3. Persist the refreshed session for next time.
|
||||
|
||||
Raises GarminError if credentials are missing or login fails.
|
||||
"""
|
||||
try:
|
||||
import garminconnect
|
||||
except ImportError as exc:
|
||||
raise GarminError(
|
||||
"garminconnect is not installed. "
|
||||
"Run: uv sync --extra garmin"
|
||||
) from exc
|
||||
|
||||
client = garminconnect.Garmin()
|
||||
|
||||
# Try cached session first
|
||||
if _load_session(user_dir, client):
|
||||
try:
|
||||
client.garth.refresh_oauth2() # renew access token if needed
|
||||
_save_session(user_dir, client) # persist refreshed token
|
||||
return client
|
||||
except Exception:
|
||||
pass # session is dead — fall through to full re-login
|
||||
|
||||
# Full login with stored credentials
|
||||
email, password = load_credentials(data_dir, user_dir)
|
||||
try:
|
||||
client = garminconnect.Garmin(email=email, password=password)
|
||||
client.login()
|
||||
except Exception as exc:
|
||||
raise GarminError(f"Garmin login failed: {exc}") from exc
|
||||
|
||||
_save_session(user_dir, client)
|
||||
return client
|
||||
|
||||
|
||||
def test_login(data_dir: Path, user_dir: Path, email: str, password: str) -> dict:
|
||||
"""Attempt a login with the supplied credentials (does not save them).
|
||||
|
||||
Returns a dict with display_name and full_name on success.
|
||||
Raises GarminError on failure.
|
||||
"""
|
||||
try:
|
||||
import garminconnect
|
||||
except ImportError as exc:
|
||||
raise GarminError("garminconnect is not installed") from exc
|
||||
|
||||
try:
|
||||
client = garminconnect.Garmin(email=email, password=password)
|
||||
client.login()
|
||||
except Exception as exc:
|
||||
raise GarminError(f"Login failed: {exc}") from exc
|
||||
|
||||
try:
|
||||
profile = client.get_profile_user_summary()
|
||||
display = profile.get("displayName", email)
|
||||
full = f"{profile.get('firstName', '')} {profile.get('lastName', '')}".strip()
|
||||
except Exception:
|
||||
display, full = email, ""
|
||||
|
||||
# Credentials are valid — save them and the session
|
||||
save_credentials(data_dir, user_dir, email, password)
|
||||
_save_session(user_dir, client)
|
||||
|
||||
return {"display_name": display, "full_name": full}
|
||||
@@ -0,0 +1,75 @@
|
||||
# Garmin Connect Sync — Disclaimer
|
||||
|
||||
**This feature uses an unofficial, community-maintained library to access Garmin Connect.
|
||||
It is not affiliated with, endorsed by, or supported by Garmin Ltd. or its subsidiaries.**
|
||||
|
||||
---
|
||||
|
||||
## What this feature does
|
||||
|
||||
When you enable Garmin Connect sync, BincioActivity will:
|
||||
|
||||
1. Ask for your Garmin Connect **email address and password**
|
||||
2. Store those credentials on the server, encrypted at rest
|
||||
3. Use them to log in to Garmin Connect on your behalf and download your activity files (FIT format)
|
||||
4. Import those activities into your BincioActivity account
|
||||
|
||||
---
|
||||
|
||||
## What you need to know before enabling this
|
||||
|
||||
### Your credentials are stored on the server
|
||||
|
||||
Unlike Strava (which uses OAuth — you authorize without sharing your password),
|
||||
Garmin Connect has no official third-party API. This feature works by logging in
|
||||
as you, using your actual email and password.
|
||||
|
||||
This means:
|
||||
|
||||
- The server operator has technical access to your stored credentials
|
||||
- You are trusting both the software and the person running the server
|
||||
- Only enable this on a server you control or run by someone you fully trust
|
||||
|
||||
### This uses an unofficial API
|
||||
|
||||
Garmin does not provide a public developer API for activity data.
|
||||
This feature relies on a reverse-engineered interface that:
|
||||
|
||||
- May break without notice when Garmin changes their systems
|
||||
- Is not covered by any Garmin service agreement or SLA
|
||||
- May violate Garmin Connect's Terms of Service
|
||||
|
||||
BincioActivity takes no responsibility for account restrictions or bans
|
||||
that may result from using this feature.
|
||||
|
||||
### Two-factor authentication (2FA)
|
||||
|
||||
If your Garmin account has 2FA enabled, this feature may not work or may
|
||||
require additional steps. Garmin has changed their authentication flow
|
||||
several times; compatibility depends on the current state of the underlying library.
|
||||
|
||||
### Rate limits
|
||||
|
||||
Garmin does not publish API rate limits. Syncing too frequently or importing
|
||||
large volumes of activities may result in temporary or permanent IP blocks.
|
||||
BincioActivity applies conservative limits, but cannot guarantee uninterrupted access.
|
||||
|
||||
---
|
||||
|
||||
## How to revoke access
|
||||
|
||||
BincioActivity does not hold an OAuth token that can be revoked from Garmin's settings.
|
||||
To stop BincioActivity from accessing your Garmin account:
|
||||
|
||||
1. Delete your stored credentials from BincioActivity (Settings → Garmin Connect → Disconnect)
|
||||
2. **Change your Garmin Connect password** — this is the only way to guarantee that
|
||||
no previously stored credentials can be used
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
If you have concerns about credential storage, consider the alternative:
|
||||
export your activities from Garmin Connect or Garmin Express as FIT files
|
||||
and upload them directly to BincioActivity. This requires no credentials
|
||||
and is always available.
|
||||
@@ -44,6 +44,10 @@ serve = [
|
||||
strava = [
|
||||
"requests>=2.32",
|
||||
]
|
||||
garmin = [
|
||||
"garminconnect>=0.2",
|
||||
"cryptography>=42.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=9.0",
|
||||
"pytest-cov>=5.0",
|
||||
|
||||
Reference in New Issue
Block a user