anafpy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. anafpy/__init__.py +25 -0
  2. anafpy/_transport/__init__.py +7 -0
  3. anafpy/_transport/base.py +145 -0
  4. anafpy/auth/__init__.py +25 -0
  5. anafpy/auth/callback.py +215 -0
  6. anafpy/auth/models.py +94 -0
  7. anafpy/auth/oauth.py +119 -0
  8. anafpy/auth/provider.py +132 -0
  9. anafpy/auth/store.py +230 -0
  10. anafpy/cli/__init__.py +1 -0
  11. anafpy/cli/main.py +299 -0
  12. anafpy/efactura/__init__.py +48 -0
  13. anafpy/efactura/client.py +509 -0
  14. anafpy/efactura/models.py +425 -0
  15. anafpy/efactura/ubl/__init__.py +1 -0
  16. anafpy/efactura/ubl/common/__init__.py +5852 -0
  17. anafpy/efactura/ubl/common/ccts_cct_schema_module_2_1.py +1127 -0
  18. anafpy/efactura/ubl/common/ubl_common_aggregate_components_2_1.py +55291 -0
  19. anafpy/efactura/ubl/common/ubl_common_basic_components_2_1.py +12592 -0
  20. anafpy/efactura/ubl/common/ubl_common_extension_components_2_1.py +297 -0
  21. anafpy/efactura/ubl/common/ubl_common_signature_components_2_1.py +60 -0
  22. anafpy/efactura/ubl/common/ubl_extension_content_data_type_2_1.py +30 -0
  23. anafpy/efactura/ubl/common/ubl_signature_aggregate_components_2_1.py +86 -0
  24. anafpy/efactura/ubl/common/ubl_signature_basic_components_2_1.py +24 -0
  25. anafpy/efactura/ubl/common/ubl_unqualified_data_types_2_1.py +577 -0
  26. anafpy/efactura/ubl/common/ubl_xad_esv132_2_1.py +1613 -0
  27. anafpy/efactura/ubl/common/ubl_xad_esv141_2_1.py +58 -0
  28. anafpy/efactura/ubl/common/ubl_xmldsig_core_schema_2_1.py +881 -0
  29. anafpy/efactura/ubl/maindoc/__init__.py +15 -0
  30. anafpy/efactura/ubl/maindoc/ubl_credit_note_2_1.py +1151 -0
  31. anafpy/efactura/ubl/maindoc/ubl_invoice_2_1.py +1229 -0
  32. anafpy/etransport/__init__.py +61 -0
  33. anafpy/etransport/client.py +376 -0
  34. anafpy/etransport/models.py +1048 -0
  35. anafpy/etransport/schema/__init__.py +49 -0
  36. anafpy/etransport/schema/schema_etr_v2_20230126.py +1143 -0
  37. anafpy/exceptions.py +67 -0
  38. anafpy/mcp/__init__.py +13 -0
  39. anafpy/mcp/__main__.py +8 -0
  40. anafpy/mcp/config.py +140 -0
  41. anafpy/mcp/context.py +144 -0
  42. anafpy/mcp/documents.py +68 -0
  43. anafpy/mcp/models.py +83 -0
  44. anafpy/mcp/nomenclatures.py +119 -0
  45. anafpy/mcp/server/__init__.py +39 -0
  46. anafpy/mcp/server/_shared.py +15 -0
  47. anafpy/mcp/server/app.py +107 -0
  48. anafpy/mcp/server/efactura.py +221 -0
  49. anafpy/mcp/server/etransport.py +375 -0
  50. anafpy/mcp/server/prompts.py +54 -0
  51. anafpy/mcp/server/public.py +91 -0
  52. anafpy/mcp/server/resources.py +45 -0
  53. anafpy/mcp/skills.py +71 -0
  54. anafpy/mcp/tokens.py +124 -0
  55. anafpy/mcp/unitcodes.py +127 -0
  56. anafpy/public/__init__.py +57 -0
  57. anafpy/public/client.py +445 -0
  58. anafpy/public/models.py +368 -0
  59. anafpy/py.typed +0 -0
  60. anafpy-0.1.0.dist-info/METADATA +417 -0
  61. anafpy-0.1.0.dist-info/RECORD +65 -0
  62. anafpy-0.1.0.dist-info/WHEEL +4 -0
  63. anafpy-0.1.0.dist-info/entry_points.txt +3 -0
  64. anafpy-0.1.0.dist-info/licenses/LICENSE +173 -0
  65. anafpy-0.1.0.dist-info/licenses/NOTICE +17 -0
anafpy/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """anafpy — typed Python clients for ANAF e-Factura, e-Transport, and the public
2
+ no-auth services (registries, financial statements)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from .exceptions import (
7
+ AnafAuthError,
8
+ AnafConfigError,
9
+ AnafError,
10
+ AnafRateLimitError,
11
+ AnafResponseError,
12
+ AnafTransportError,
13
+ )
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "AnafAuthError",
19
+ "AnafConfigError",
20
+ "AnafError",
21
+ "AnafRateLimitError",
22
+ "AnafResponseError",
23
+ "AnafTransportError",
24
+ "__version__",
25
+ ]
@@ -0,0 +1,7 @@
1
+ """Shared transport layer (environment, per-service base URLs)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import OAUTH_HOST, Environment, Service, service_base_url
6
+
7
+ __all__ = ["OAUTH_HOST", "Environment", "Service", "service_base_url"]
@@ -0,0 +1,145 @@
1
+ """Shared transport concerns: environment, per-service base URLs, error raising.
2
+
3
+ Both OAuth services are reached on the **same host** ``api.anaf.ro``; they differ
4
+ only by **path prefix** (``/FCTEL/rest`` vs ``/ETRANSPORT/ws/v1``). See
5
+ ``docs/anaf-reference/{efactura,etransport}/api.md``.
6
+
7
+ The unauthenticated public services (registries, financial statements) live on a
8
+ different host, ``webservicesp.anaf.ro``, with **no test/prod split** — so
9
+ :data:`PUBLIC_HOST` sits outside the :func:`service_base_url` scheme. See
10
+ ``docs/anaf-reference/public/api.md``.
11
+
12
+ :func:`raise_for_status` and :func:`as_text` implement the error half of the hybrid
13
+ model shared by all clients: non-success HTTP raises (429 as
14
+ :class:`~anafpy.exceptions.AnafRateLimitError` with ``retry_after``); business
15
+ outcomes are the clients' job to return as values.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ import unicodedata
22
+ from email.utils import parsedate_to_datetime
23
+ from enum import StrEnum
24
+ from zoneinfo import ZoneInfo
25
+
26
+ import httpx
27
+
28
+ from ..exceptions import AnafRateLimitError, AnafResponseError
29
+
30
+ __all__ = [
31
+ "OAUTH_HOST",
32
+ "PUBLIC_HOST",
33
+ "ROMANIA_TZ",
34
+ "Environment",
35
+ "Service",
36
+ "as_text",
37
+ "is_empty_result_message",
38
+ "raise_for_status",
39
+ "retry_after_seconds",
40
+ "service_base_url",
41
+ ]
42
+
43
+ #: ANAF's clock. "Today"/"now" *defaults* (a registry query's as-of date, a vehicle
44
+ #: change's timestamp) are ANAF-semantic values, so they are computed in Romania's
45
+ #: timezone rather than the machine's. Caller-supplied values are never converted.
46
+ ROMANIA_TZ = ZoneInfo("Europe/Bucharest")
47
+
48
+ #: OAuth2 API host for both services (the cert-direct host ``webserviceapl.anaf.ro`` is
49
+ #: intentionally not used by anafpy).
50
+ OAUTH_HOST = "https://api.anaf.ro"
51
+
52
+ #: Host of the unauthenticated public services (registries, financial statements).
53
+ #: Production only — ANAF exposes no TEST variant for these.
54
+ PUBLIC_HOST = "https://webservicesp.anaf.ro"
55
+
56
+
57
+ class Environment(StrEnum):
58
+ """ANAF API environment."""
59
+
60
+ TEST = "test"
61
+ PROD = "prod"
62
+
63
+
64
+ class Service(StrEnum):
65
+ """Path prefixes per ANAF service (under the OAuth host)."""
66
+
67
+ EFACTURA = "FCTEL/rest"
68
+ ETRANSPORT = "ETRANSPORT/ws/v1"
69
+
70
+
71
+ def service_base_url(service: Service, environment: Environment) -> str:
72
+ """Return e.g. ``https://api.anaf.ro/test/FCTEL/rest``."""
73
+ return f"{OAUTH_HOST}/{environment.value}/{service.value}"
74
+
75
+
76
+ def retry_after_seconds(value: str | None) -> float | None:
77
+ """Parse a ``Retry-After`` header into seconds.
78
+
79
+ RFC 9110 allows either a delay in seconds or an HTTP-date; an unparseable
80
+ value yields ``None`` rather than masking the rate-limit error it decorates.
81
+ """
82
+ if value is None:
83
+ return None
84
+ try:
85
+ return float(value)
86
+ except ValueError:
87
+ pass
88
+ try:
89
+ delay = parsedate_to_datetime(value).timestamp() - time.time()
90
+ except ValueError:
91
+ return None
92
+ return max(delay, 0.0)
93
+
94
+
95
+ def as_text(body: bytes) -> str:
96
+ """Decode a response body for diagnostics (lossy — replacement characters)."""
97
+ return body.decode("utf-8", errors="replace")
98
+
99
+
100
+ def raise_for_status(response: httpx.Response) -> None:
101
+ """Raise the anafpy error for a non-success response; a no-op on success.
102
+
103
+ HTTP 429 raises :class:`~anafpy.exceptions.AnafRateLimitError` carrying
104
+ ``retry_after``; anything else non-success raises
105
+ :class:`~anafpy.exceptions.AnafResponseError`. No auto-backoff — per the
106
+ design, the caller decides how to retry.
107
+ """
108
+ if response.is_success:
109
+ return
110
+ body = as_text(response.content)
111
+ if response.status_code == httpx.codes.TOO_MANY_REQUESTS:
112
+ raise AnafRateLimitError(
113
+ retry_after=retry_after_seconds(response.headers.get("Retry-After")),
114
+ body=body,
115
+ )
116
+ raise AnafResponseError(
117
+ f"ANAF returned HTTP {response.status_code}",
118
+ status_code=response.status_code,
119
+ body=body,
120
+ )
121
+
122
+
123
+ #: Substrings (accent-stripped, casefolded) that mark an ANAF list ``eroare`` as a
124
+ #: benign "no results in this window" note rather than a real fault. ANAF overloads the
125
+ #: same ``eroare`` field for both, so the wording is the only signal; extend as needed.
126
+ _EMPTY_RESULT_MARKERS = (
127
+ "nu exista mesaje",
128
+ "nu sunt mesaje",
129
+ "nu exista facturi",
130
+ "nu exista notificari",
131
+ "nu exista inregistrari",
132
+ "nu exista date",
133
+ "nu exista informatii", # e-Transport `info` no-results (live-confirmed 2026-07-02)
134
+ )
135
+
136
+
137
+ def is_empty_result_message(text: str) -> bool:
138
+ """True when an ANAF list ``eroare`` string denotes *no results* (empty window),
139
+ not a genuine error (bad CIF/interval). Matched accent-insensitively."""
140
+ normalized = "".join(
141
+ ch
142
+ for ch in unicodedata.normalize("NFKD", text)
143
+ if not unicodedata.combining(ch)
144
+ ).casefold()
145
+ return any(marker in normalized for marker in _EMPTY_RESULT_MARKERS)
@@ -0,0 +1,25 @@
1
+ """ANAF OAuth2 authentication: token model, storage, bootstrap, and refresh."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .callback import CallbackListener, capture_authorization_code, parse_redirect_url
6
+ from .models import TokenSet
7
+ from .oauth import build_authorize_url, exchange_code, refresh_tokens
8
+ from .provider import AnafAuth, TokenProvider
9
+ from .store import FileTokenStore, KeyringTokenStore, MemoryTokenStore, TokenStore
10
+
11
+ __all__ = [
12
+ "AnafAuth",
13
+ "CallbackListener",
14
+ "FileTokenStore",
15
+ "KeyringTokenStore",
16
+ "MemoryTokenStore",
17
+ "TokenProvider",
18
+ "TokenSet",
19
+ "TokenStore",
20
+ "build_authorize_url",
21
+ "capture_authorization_code",
22
+ "exchange_code",
23
+ "parse_redirect_url",
24
+ "refresh_tokens",
25
+ ]
@@ -0,0 +1,215 @@
1
+ """Authorization-code capture for the one-time ``auth login`` bootstrap.
2
+
3
+ ANAF redirects the browser (after the certificate step) to the registered callback URL
4
+ with ``?code=...``. The ANAF developer portal **rejects ``http://`` callback URLs**
5
+ (HTTP 400 at registration — verified 2026-07-02), so the registered URL is ``https://``
6
+ and there are two ways to capture the code:
7
+
8
+ - **Listener** (:class:`CallbackListener` / ``capture_authorization_code``): a tiny
9
+ local server on the callback URL's host/port. Plain HTTP by default (put a TLS
10
+ terminator in front), or pass an ``ssl.SSLContext`` to serve TLS directly with a
11
+ certificate you supply. The listener **binds on construction** so the browser can be
12
+ opened only once the port is actually listening — a fast redirect (cached
13
+ certificate/session) must never outrun the bind.
14
+ - **Paste mode** (``parse_redirect_url``): run no listener at all. The browser lands on
15
+ a connection error, but the address bar still holds the full redirect URL; the user
16
+ pastes it (or just the code) into the CLI. Works everywhere, needs no certificate.
17
+
18
+ Both capture paths take an ``expected_state``: the CLI binds a random OAuth ``state``
19
+ to each login attempt, and a redirect that does not echo it back is rejected (the
20
+ listener answers 400 and keeps waiting) — so a forged redirect cannot inject an
21
+ attacker's authorization code into the flow (login CSRF).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hmac
27
+ import ssl
28
+ import threading
29
+ import urllib.parse
30
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
31
+
32
+ from ..exceptions import AnafAuthError, AnafConfigError
33
+
34
+ __all__ = ["CallbackListener", "capture_authorization_code", "parse_redirect_url"]
35
+
36
+ _PAGE = (
37
+ b"<!doctype html><meta charset=utf-8><title>anafpy</title>"
38
+ b"<body style='font-family:sans-serif;padding:2rem'>"
39
+ b"<h2>%s</h2><p>You can close this tab and return to the terminal.</p>"
40
+ )
41
+
42
+
43
+ def _state_matches(received: str, expected: str) -> bool:
44
+ """Constant-time ``state`` comparison. Compared as bytes:
45
+ ``hmac.compare_digest`` raises ``TypeError`` on non-ASCII *strings*, and the
46
+ received value is attacker-influenced."""
47
+ return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))
48
+
49
+
50
+ def parse_redirect_url(pasted: str, *, expected_state: str | None = None) -> str:
51
+ """Extract the authorization code from a pasted redirect URL (paste mode).
52
+
53
+ Accepts the full redirect URL from the browser address bar, a bare
54
+ ``code=...&...`` query string, or the code value alone. When
55
+ ``expected_state`` is given, a pasted URL must echo it back (a bare code —
56
+ a deliberate manual extraction — is exempt).
57
+
58
+ Raises ``AnafAuthError`` on an OAuth error redirect, a ``state`` mismatch,
59
+ or unrecognizable input.
60
+ """
61
+ text = pasted.strip().strip("'\"")
62
+ if not text:
63
+ raise AnafAuthError("empty input: paste the full redirect URL from the browser")
64
+
65
+ query = urllib.parse.urlparse(text).query
66
+ if not query and "=" in text:
67
+ query = text.lstrip("?")
68
+ if query:
69
+ params = urllib.parse.parse_qs(query)
70
+ if expected_state is not None and not _state_matches(
71
+ params.get("state", [""])[0], expected_state
72
+ ):
73
+ raise AnafAuthError(
74
+ "state mismatch: the pasted URL is not from this login attempt — "
75
+ "restart `anafpy auth login` and paste the redirect it produces "
76
+ "(or paste just the code value)"
77
+ )
78
+ if "code" in params:
79
+ return params["code"][0]
80
+ if "error" in params:
81
+ raise AnafAuthError(f"authorization failed: {params['error'][0]}")
82
+ raise AnafAuthError("no `code` parameter in the pasted URL")
83
+
84
+ if "/" in text or " " in text:
85
+ raise AnafAuthError("no `code` parameter in the pasted URL")
86
+ return text # the bare code value
87
+
88
+
89
+ class CallbackListener:
90
+ """A bound-and-listening receiver for the OAuth redirect.
91
+
92
+ Binding happens in the constructor (raising :class:`AnafConfigError` when the
93
+ host/port cannot be bound), so callers can open the browser only *after* the
94
+ listener is provably up, then :meth:`wait` for the redirect. Use as a context
95
+ manager (or call :meth:`close`) to stop the server.
96
+
97
+ When ``expected_state`` is given, only a redirect echoing that OAuth ``state``
98
+ completes the wait; anything else (a forged redirect trying to inject a code,
99
+ login CSRF) is answered 400 and the listener keeps waiting for the real one.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ redirect_uri: str,
105
+ *,
106
+ ssl_context: ssl.SSLContext | None = None,
107
+ expected_state: str | None = None,
108
+ ) -> None:
109
+ parsed = urllib.parse.urlparse(redirect_uri)
110
+ host = parsed.hostname or "localhost"
111
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
112
+ expected_path = parsed.path or "/"
113
+
114
+ result: dict[str, str] = {}
115
+ done = threading.Event()
116
+ self._result = result
117
+ self._done = done
118
+
119
+ class Handler(BaseHTTPRequestHandler):
120
+ def log_message(self, format: str, *args: object) -> None:
121
+ pass # silence default request logging
122
+
123
+ def do_GET(self) -> None:
124
+ url = urllib.parse.urlparse(self.path)
125
+ if url.path != expected_path:
126
+ self.send_response(404)
127
+ self.end_headers()
128
+ return
129
+ query = urllib.parse.parse_qs(url.query)
130
+ if expected_state is not None and not _state_matches(
131
+ query.get("state", [""])[0], expected_state
132
+ ):
133
+ # Not this login attempt's redirect: refuse it and keep
134
+ # waiting for the one carrying our state.
135
+ self.send_response(400)
136
+ self.end_headers()
137
+ return
138
+ if "code" in query:
139
+ result["code"] = query["code"][0]
140
+ msg = b"Authorization received."
141
+ else:
142
+ result["error"] = query.get("error", ["unknown_error"])[0]
143
+ msg = b"Authorization failed."
144
+ # The outcome is captured; release the waiter even if the browser
145
+ # drops the connection before the response page is written.
146
+ try:
147
+ self.send_response(200)
148
+ self.send_header("Content-Type", "text/html; charset=utf-8")
149
+ self.end_headers()
150
+ self.wfile.write(_PAGE % msg)
151
+ finally:
152
+ done.set()
153
+
154
+ try:
155
+ self._server = ThreadingHTTPServer((host, port), Handler)
156
+ if ssl_context is not None:
157
+ self._server.socket = ssl_context.wrap_socket(
158
+ self._server.socket, server_side=True
159
+ )
160
+ except OSError as exc:
161
+ raise AnafConfigError(
162
+ f"cannot bind callback listener on {host}:{port}: {exc}"
163
+ ) from exc
164
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
165
+ self._thread.start()
166
+
167
+ def __enter__(self) -> CallbackListener:
168
+ return self
169
+
170
+ def __exit__(self, *exc_info: object) -> None:
171
+ self.close()
172
+
173
+ def wait(self, timeout: float = 180.0) -> str | None:
174
+ """Block until the redirect arrives; the code, or ``None`` on timeout.
175
+
176
+ Raises ``AnafAuthError`` on an OAuth error redirect.
177
+ """
178
+ if not self._done.wait(timeout):
179
+ return None
180
+ if "error" in self._result:
181
+ raise AnafAuthError(f"authorization failed: {self._result['error']}")
182
+ return self._result["code"]
183
+
184
+ def close(self) -> None:
185
+ self._server.shutdown()
186
+ self._server.server_close()
187
+
188
+
189
+ def capture_authorization_code(
190
+ redirect_uri: str,
191
+ *,
192
+ timeout: float = 180.0,
193
+ ssl_context: ssl.SSLContext | None = None,
194
+ expected_state: str | None = None,
195
+ ) -> str:
196
+ """Block until ANAF redirects to ``redirect_uri`` with a code; return that code.
197
+
198
+ Bind-and-wait in one call, for callers that already opened the browser (prefer
199
+ :class:`CallbackListener` to bind *before* opening it). Plain HTTP unless
200
+ ``ssl_context`` is given, in which case it serves TLS with that context (use
201
+ this when the browser must reach the callback over ``https://`` and no
202
+ external TLS terminator is in front). ``expected_state`` is enforced as in
203
+ :class:`CallbackListener`.
204
+
205
+ Raises ``AnafAuthError`` on an OAuth error redirect or on timeout.
206
+ """
207
+ with CallbackListener(
208
+ redirect_uri, ssl_context=ssl_context, expected_state=expected_state
209
+ ) as listener:
210
+ code = listener.wait(timeout)
211
+ if code is None:
212
+ raise AnafAuthError(
213
+ f"timed out waiting for the OAuth callback ({timeout:.0f}s)"
214
+ )
215
+ return code
anafpy/auth/models.py ADDED
@@ -0,0 +1,94 @@
1
+ """Token model.
2
+
3
+ ANAF access tokens are JWTs valid 90 days; refresh tokens 365 days. Refresh **rotates**
4
+ the refresh token (a new access *and* refresh token come back), so both are persisted.
5
+ Expiry timestamps are **computed from the JWTs themselves** (cached per instance), so
6
+ the persisted store can never drift from the tokens it holds.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from functools import cached_property
13
+ from typing import Any
14
+
15
+ import jwt
16
+ from pydantic import BaseModel, Field, computed_field
17
+
18
+ __all__ = ["TokenSet"]
19
+
20
+ # Fallback lifetimes (s) if a JWT `exp` can't be read. Source: official OAuth PDF.
21
+ _ACCESS_TTL = 90 * 24 * 3600
22
+ _REFRESH_TTL = 365 * 24 * 3600
23
+
24
+
25
+ def _jwt_exp(token: str) -> float | None:
26
+ """Best-effort read of a JWT's ``exp`` claim (epoch seconds), without verifying.
27
+
28
+ Signature verification is ANAF's job server-side; here we only read ``exp`` to
29
+ schedule refresh. Returns ``None`` for non-JWT or unparseable tokens.
30
+ """
31
+ try:
32
+ payload = jwt.decode(token, options={"verify_signature": False})
33
+ except jwt.InvalidTokenError:
34
+ return None
35
+ exp = payload.get("exp")
36
+ return float(exp) if isinstance(exp, (int, float)) else None
37
+
38
+
39
+ class TokenSet(BaseModel):
40
+ """An access/refresh token pair; expiries are derived from the JWT ``exp`` claims.
41
+
42
+ Instances are treated as immutable: refresh rotation builds a *new* ``TokenSet``
43
+ via :meth:`from_token_response`, which is what makes the cached expiry properties
44
+ safe.
45
+ """
46
+
47
+ access_token: str
48
+ refresh_token: str
49
+ token_type: str = "Bearer"
50
+ obtained_at: float = Field(default_factory=time.time)
51
+ # Lifetime (s) from the token response, used only if the access JWT has no `exp`.
52
+ expires_in: float | None = None
53
+
54
+ # Computed expiries are serialized into the store (for human inspection) but
55
+ # ignored on load. The `type: ignore`s are pydantic's documented workaround for
56
+ # mypy's blanket "decorators on top of @property" limitation.
57
+ @computed_field # type: ignore[prop-decorator]
58
+ @cached_property
59
+ def access_expires_at(self) -> float:
60
+ """Access expiry (epoch s): JWT ``exp``, else ``expires_in``, else 90 days."""
61
+ exp = _jwt_exp(self.access_token)
62
+ if exp is not None:
63
+ return exp
64
+ if self.expires_in:
65
+ return self.obtained_at + self.expires_in
66
+ return self.obtained_at + _ACCESS_TTL
67
+
68
+ @computed_field # type: ignore[prop-decorator]
69
+ @cached_property
70
+ def refresh_expires_at(self) -> float:
71
+ """Refresh expiry (epoch s): JWT ``exp``, else the documented 365 days."""
72
+ return _jwt_exp(self.refresh_token) or (self.obtained_at + _REFRESH_TTL)
73
+
74
+ @classmethod
75
+ def from_token_response(
76
+ cls, data: dict[str, Any], *, obtained_at: float | None = None
77
+ ) -> TokenSet:
78
+ """Build from ANAF's JSON token response (`access_token`, `refresh_token`)."""
79
+ expires_in = data.get("expires_in")
80
+ return cls(
81
+ access_token=str(data["access_token"]),
82
+ refresh_token=str(data["refresh_token"]),
83
+ token_type=str(data.get("token_type", "Bearer")),
84
+ obtained_at=obtained_at if obtained_at is not None else time.time(),
85
+ expires_in=float(expires_in) if expires_in else None,
86
+ )
87
+
88
+ def access_expired(self, *, leeway: float = 300.0) -> bool:
89
+ """True if the access token is expired (or within ``leeway`` seconds of it)."""
90
+ return time.time() >= (self.access_expires_at - leeway)
91
+
92
+ def refresh_expired(self) -> bool:
93
+ """True if the refresh token has likely expired (re-auth required)."""
94
+ return time.time() >= self.refresh_expires_at
anafpy/auth/oauth.py ADDED
@@ -0,0 +1,119 @@
1
+ """ANAF OAuth2 endpoints and operations.
2
+
3
+ Verified against the official OAuth procedure PDF and a live probe (2026-06-28):
4
+ client auth is **HTTP Basic** (client_id:client_secret); ``token_content_type`` is
5
+ ``jwt`` (query for /authorize, body for /token); refresh is **headless** (no
6
+ certificate). See ``docs/anaf-reference/oauth/authentication.md``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import urllib.parse
12
+
13
+ import httpx
14
+
15
+ from ..exceptions import AnafAuthError, AnafTransportError
16
+ from .models import TokenSet
17
+
18
+ __all__ = [
19
+ "AUTHORIZE_URL",
20
+ "REVOKE_URL",
21
+ "TOKEN_URL",
22
+ "build_authorize_url",
23
+ "exchange_code",
24
+ "refresh_tokens",
25
+ ]
26
+
27
+ AUTHORIZE_URL = "https://logincert.anaf.ro/anaf-oauth2/v1/authorize"
28
+ TOKEN_URL = "https://logincert.anaf.ro/anaf-oauth2/v1/token"
29
+ #: Documented by ANAF's PDF but NOT reachable headlessly (live-probed 2026-07-05:
30
+ #: a client-secret-authenticated POST gets the F5 APM login-wall redirect, identical
31
+ #: to a nonexistent path). Kept as a documented fact; nothing in anafpy calls it.
32
+ REVOKE_URL = "https://logincert.anaf.ro/anaf-oauth2/v1/revoke"
33
+
34
+
35
+ def build_authorize_url(
36
+ client_id: str, redirect_uri: str, *, state: str | None = None
37
+ ) -> str:
38
+ """Build the browser authorization URL (the cert step happens here)."""
39
+ params = {
40
+ "response_type": "code",
41
+ "client_id": client_id,
42
+ "redirect_uri": redirect_uri,
43
+ "token_content_type": "jwt",
44
+ }
45
+ if state is not None:
46
+ params["state"] = state
47
+ return f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
48
+
49
+
50
+ async def _post_token(
51
+ http: httpx.AsyncClient,
52
+ client_id: str,
53
+ client_secret: str,
54
+ data: dict[str, str],
55
+ ) -> TokenSet:
56
+ data = {**data, "token_content_type": "jwt"}
57
+ try:
58
+ resp = await http.post(
59
+ TOKEN_URL,
60
+ data=data,
61
+ auth=httpx.BasicAuth(client_id, client_secret),
62
+ headers={"Accept": "application/json"},
63
+ )
64
+ except httpx.HTTPError as exc: # connection/timeout
65
+ raise AnafTransportError(f"token request failed: {exc}") from exc
66
+
67
+ if resp.status_code != httpx.codes.OK:
68
+ # ANAF returns OAuth error JSON, e.g. {"error":"invalid_grant", ...}.
69
+ detail = resp.text
70
+ try:
71
+ payload = resp.json()
72
+ detail = payload.get("error_description") or payload.get("error") or detail
73
+ except ValueError:
74
+ pass
75
+ raise AnafAuthError(
76
+ f"token endpoint returned HTTP {resp.status_code}: {detail}"
77
+ )
78
+
79
+ return TokenSet.from_token_response(resp.json())
80
+
81
+
82
+ async def exchange_code(
83
+ http: httpx.AsyncClient,
84
+ *,
85
+ client_id: str,
86
+ client_secret: str,
87
+ code: str,
88
+ redirect_uri: str,
89
+ ) -> TokenSet:
90
+ """Exchange an authorization ``code`` for a token set (no certificate needed)."""
91
+ return await _post_token(
92
+ http,
93
+ client_id,
94
+ client_secret,
95
+ {
96
+ "grant_type": "authorization_code",
97
+ "code": code,
98
+ "redirect_uri": redirect_uri,
99
+ },
100
+ )
101
+
102
+
103
+ async def refresh_tokens(
104
+ http: httpx.AsyncClient,
105
+ *,
106
+ client_id: str,
107
+ client_secret: str,
108
+ refresh_token: str,
109
+ ) -> TokenSet:
110
+ """Get a fresh token set from a refresh token (headless; rotates refresh token)."""
111
+ return await _post_token(
112
+ http,
113
+ client_id,
114
+ client_secret,
115
+ {
116
+ "grant_type": "refresh_token",
117
+ "refresh_token": refresh_token,
118
+ },
119
+ )