Coverage for src/bayernwerk_client/tokens.py: 100%
44 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 13:07 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 13:07 +0000
1"""Generic JWT-based token storage, shared across services.
3Both `map` and `efix` authenticate via a Salesforce-issued JWT with an
4`exp` claim and cache it locally the same way, so this lives here rather
5than under either service. Each service passes its own distinct
6`TokenStore` path (e.g. `map-tokens.json` / `efix-tokens.json`) - two
7services sharing the generic default path below would silently overwrite
8each other's cached token.
9"""
11from __future__ import annotations
13import json
14import os
15import time
16from dataclasses import asdict, dataclass
17from pathlib import Path
19from bayernwerk_client._jwt import decode_jwt_payload
21DEFAULT_TOKEN_PATH = Path.home() / ".cache" / "bayernwerk-client" / "tokens.json"
22"""Generic fallback for ad-hoc use - services define and pass their own path."""
24# Refresh a bit before actual expiry to avoid racing a request against expiry.
25EXPIRY_LEEWAY_SECONDS = 60
28@dataclass
29class TokenSet:
30 access_token: str
31 refresh_token: str | None = None
32 id_token: str | None = None
33 expires_at: float | None = None
34 """Unix timestamp, decoded from the access token's `exp` claim."""
36 @classmethod
37 def from_access_token(
38 cls, access_token: str, *, refresh_token: str | None = None, id_token: str | None = None
39 ) -> TokenSet:
40 expires_at: float | None = None
41 try:
42 expires_at = float(decode_jwt_payload(access_token)["exp"])
43 except (ValueError, KeyError, TypeError):
44 expires_at = None
45 return cls(access_token=access_token, refresh_token=refresh_token, id_token=id_token, expires_at=expires_at)
47 @property
48 def is_expired(self) -> bool:
49 if self.expires_at is None:
50 return False
51 return time.time() >= (self.expires_at - EXPIRY_LEEWAY_SECONDS)
54class TokenStore:
55 """Reads/writes a `TokenSet` to a local JSON file with restrictive permissions."""
57 def __init__(self, path: Path | str = DEFAULT_TOKEN_PATH) -> None:
58 self.path = Path(path)
60 def load(self) -> TokenSet | None:
61 if not self.path.exists():
62 return None
63 data = json.loads(self.path.read_text())
64 return TokenSet(**data)
66 def save(self, tokens: TokenSet) -> None:
67 self.path.parent.mkdir(parents=True, exist_ok=True)
68 self.path.write_text(json.dumps(asdict(tokens)))
69 os.chmod(self.path, 0o600)
71 def clear(self) -> None:
72 self.path.unlink(missing_ok=True)