better authenticarion

This commit is contained in:
2026-08-30 11:33:13 +02:00
parent fcae3569fc
commit ea591fc8fb
10 changed files with 299 additions and 16 deletions
+73 -3
View File
@@ -2,6 +2,9 @@ from __future__ import annotations
import json
import logging
import os
from pathlib import Path
import tempfile
import time
import urllib.error
import urllib.parse
@@ -14,6 +17,43 @@ class NetatmoError(RuntimeError):
pass
class TokenStore:
"""Persist OAuth tokens atomically in a file readable only by its owner."""
def __init__(self, path: Path | str):
self.path = Path(path)
def load(self) -> dict:
if not self.path.exists():
return {}
try:
with self.path.open(encoding="utf-8") as token_file:
data = json.load(token_file)
except (OSError, ValueError) as exc:
raise NetatmoError(f"Cannot read token file {self.path}: {exc}") from exc
return data if isinstance(data, dict) else {}
def save(self, tokens: dict) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{self.path.name}.", dir=self.path.parent
)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as token_file:
json.dump(tokens, token_file, indent=2, sort_keys=True)
token_file.write("\n")
token_file.flush()
os.fsync(token_file.fileno())
os.replace(temporary_name, self.path)
except Exception:
try:
os.unlink(temporary_name)
except FileNotFoundError:
pass
raise
class NetatmoClient:
"""Small dependency-free Netatmo OAuth and Weather API client."""
@@ -22,10 +62,14 @@ class NetatmoClient:
self.client_secret = config["NETATMO_CLIENT_SECRET"]
self.refresh_token = config["NETATMO_REFRESH_TOKEN"]
self.access_token = config["NETATMO_ACCESS_TOKEN"]
self.token_store = TokenStore(config["NETATMO_TOKEN_FILE"])
self.device_id = config["NETATMO_DEVICE_ID"]
self.token_url = config["NETATMO_TOKEN_URL"]
self.stations_url = config["NETATMO_STATIONS_URL"]
self._expires_at = 0.0
stored = self.token_store.load()
self.access_token = stored.get("access_token", self.access_token)
self.refresh_token = stored.get("refresh_token", self.refresh_token)
self._expires_at = float(stored.get("expires_at", 0))
@property
def configured(self) -> bool:
@@ -49,10 +93,37 @@ class NetatmoClient:
}
).encode()
result = self._request(urllib.request.Request(self.token_url, data=data, method="POST"))
self._accept_tokens(result)
def exchange_code(self, code: str, redirect_uri: str) -> dict:
"""Exchange a first-time OAuth authorization code and persist its tokens."""
data = urllib.parse.urlencode(
{
"grant_type": "authorization_code",
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"redirect_uri": redirect_uri,
}
).encode()
result = self._request(urllib.request.Request(self.token_url, data=data, method="POST"))
self._accept_tokens(result)
return result
def _accept_tokens(self, result: dict) -> None:
if "access_token" not in result:
raise NetatmoError("Netatmo token response contains no access token")
self.access_token = result["access_token"]
# Netatmo may rotate refresh tokens; retain the new value for this process.
self.refresh_token = result.get("refresh_token", self.refresh_token)
self._expires_at = time.time() + int(result.get("expires_in", 10800)) - 60
persisted = {
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"expires_at": self._expires_at,
}
if result.get("scope"):
persisted["scope"] = result["scope"]
self.token_store.save(persisted)
def stations_data(self) -> dict:
if not self.configured:
@@ -74,4 +145,3 @@ class NetatmoClient:
self._refresh()
request.headers["Authorization"] = f"Bearer {self.access_token}"
return self._request(request)