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.
- anafpy/__init__.py +25 -0
- anafpy/_transport/__init__.py +7 -0
- anafpy/_transport/base.py +145 -0
- anafpy/auth/__init__.py +25 -0
- anafpy/auth/callback.py +215 -0
- anafpy/auth/models.py +94 -0
- anafpy/auth/oauth.py +119 -0
- anafpy/auth/provider.py +132 -0
- anafpy/auth/store.py +230 -0
- anafpy/cli/__init__.py +1 -0
- anafpy/cli/main.py +299 -0
- anafpy/efactura/__init__.py +48 -0
- anafpy/efactura/client.py +509 -0
- anafpy/efactura/models.py +425 -0
- anafpy/efactura/ubl/__init__.py +1 -0
- anafpy/efactura/ubl/common/__init__.py +5852 -0
- anafpy/efactura/ubl/common/ccts_cct_schema_module_2_1.py +1127 -0
- anafpy/efactura/ubl/common/ubl_common_aggregate_components_2_1.py +55291 -0
- anafpy/efactura/ubl/common/ubl_common_basic_components_2_1.py +12592 -0
- anafpy/efactura/ubl/common/ubl_common_extension_components_2_1.py +297 -0
- anafpy/efactura/ubl/common/ubl_common_signature_components_2_1.py +60 -0
- anafpy/efactura/ubl/common/ubl_extension_content_data_type_2_1.py +30 -0
- anafpy/efactura/ubl/common/ubl_signature_aggregate_components_2_1.py +86 -0
- anafpy/efactura/ubl/common/ubl_signature_basic_components_2_1.py +24 -0
- anafpy/efactura/ubl/common/ubl_unqualified_data_types_2_1.py +577 -0
- anafpy/efactura/ubl/common/ubl_xad_esv132_2_1.py +1613 -0
- anafpy/efactura/ubl/common/ubl_xad_esv141_2_1.py +58 -0
- anafpy/efactura/ubl/common/ubl_xmldsig_core_schema_2_1.py +881 -0
- anafpy/efactura/ubl/maindoc/__init__.py +15 -0
- anafpy/efactura/ubl/maindoc/ubl_credit_note_2_1.py +1151 -0
- anafpy/efactura/ubl/maindoc/ubl_invoice_2_1.py +1229 -0
- anafpy/etransport/__init__.py +61 -0
- anafpy/etransport/client.py +376 -0
- anafpy/etransport/models.py +1048 -0
- anafpy/etransport/schema/__init__.py +49 -0
- anafpy/etransport/schema/schema_etr_v2_20230126.py +1143 -0
- anafpy/exceptions.py +67 -0
- anafpy/mcp/__init__.py +13 -0
- anafpy/mcp/__main__.py +8 -0
- anafpy/mcp/config.py +140 -0
- anafpy/mcp/context.py +144 -0
- anafpy/mcp/documents.py +68 -0
- anafpy/mcp/models.py +83 -0
- anafpy/mcp/nomenclatures.py +119 -0
- anafpy/mcp/server/__init__.py +39 -0
- anafpy/mcp/server/_shared.py +15 -0
- anafpy/mcp/server/app.py +107 -0
- anafpy/mcp/server/efactura.py +221 -0
- anafpy/mcp/server/etransport.py +375 -0
- anafpy/mcp/server/prompts.py +54 -0
- anafpy/mcp/server/public.py +91 -0
- anafpy/mcp/server/resources.py +45 -0
- anafpy/mcp/skills.py +71 -0
- anafpy/mcp/tokens.py +124 -0
- anafpy/mcp/unitcodes.py +127 -0
- anafpy/public/__init__.py +57 -0
- anafpy/public/client.py +445 -0
- anafpy/public/models.py +368 -0
- anafpy/py.typed +0 -0
- anafpy-0.1.0.dist-info/METADATA +417 -0
- anafpy-0.1.0.dist-info/RECORD +65 -0
- anafpy-0.1.0.dist-info/WHEEL +4 -0
- anafpy-0.1.0.dist-info/entry_points.txt +3 -0
- anafpy-0.1.0.dist-info/licenses/LICENSE +173 -0
- 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,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)
|
anafpy/auth/__init__.py
ADDED
|
@@ -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
|
+
]
|
anafpy/auth/callback.py
ADDED
|
@@ -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
|
+
)
|