memydev-auth-sdk 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.
- memyauth/__init__.py +144 -0
- memyauth/_client.py +169 -0
- memyauth/_config.py +407 -0
- memyauth/_discovery.py +172 -0
- memyauth/_errors.py +270 -0
- memyauth/_iam.py +298 -0
- memyauth/_idtoken.py +165 -0
- memyauth/_oidc.py +237 -0
- memyauth/_pkce.py +61 -0
- memyauth/_roles.py +109 -0
- memyauth/_session.py +149 -0
- memyauth/_state.py +139 -0
- memyauth/_sync/__init__.py +3 -0
- memyauth/_sync/_client.py +171 -0
- memyauth/_sync/_discovery.py +174 -0
- memyauth/_sync/_iam.py +300 -0
- memyauth/_sync/_idtoken.py +167 -0
- memyauth/_sync/_oidc.py +239 -0
- memyauth/_sync/_transport.py +223 -0
- memyauth/_transport.py +221 -0
- memyauth/_types.py +309 -0
- memyauth/py.typed +0 -0
- memyauth/sync/__init__.py +147 -0
- memydev_auth_sdk-0.1.0.dist-info/METADATA +86 -0
- memydev_auth_sdk-0.1.0.dist-info/RECORD +26 -0
- memydev_auth_sdk-0.1.0.dist-info/WHEEL +4 -0
memyauth/_sync/_oidc.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# GENERATED by scripts/gen_sync.py from memyauth/_oidc.py — DO NOT EDIT.
|
|
2
|
+
# Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
|
|
3
|
+
"""
|
|
4
|
+
@fileoverview Capabilities 2, 3, 4, 9 — authorization URL, token exchange/refresh, userinfo, logout.
|
|
5
|
+
@module memyauth._oidc
|
|
6
|
+
@description [async -> _sync/_oidc.py twin via scripts/gen_sync.py]. Ported from
|
|
7
|
+
`memybase/src/auth/oidc.ts:14-20,155-159,178-225`. Every URL this service builds comes from the
|
|
8
|
+
DISCOVERY DOCUMENT, never from a guessed path convention (§C.2, D7 — memyui's guessed
|
|
9
|
+
`{issuer}/auth`). Token exchange and refresh are POSTs and are therefore NEVER retried by
|
|
10
|
+
`Transport` (§0 C13 — an authorization code is single-use); their errors are bespoke
|
|
11
|
+
(`TokenExchangeError` / `RefreshRejectedError`) rather than the generic `decode_error` mapping,
|
|
12
|
+
because the token endpoint's failure body is an OAuth `{error, error_description}` shape, not
|
|
13
|
+
the IAM surface's `{code,message,detail}` envelope.
|
|
14
|
+
@dependencies base64, urllib.parse (stdlib); ._transport, ._pkce, ._discovery
|
|
15
|
+
@relatedFiles _discovery.py (this service's sole source of every OIDC URL), _pkce.py (PKCE +
|
|
16
|
+
random helpers), _config.py (ResolvedConfig), _errors.py, tests/test_oidc.py
|
|
17
|
+
@created 2026-09-06
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
from typing import Optional
|
|
23
|
+
from urllib.parse import urlencode
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
# ABSOLUTE imports for the shared/pure modules; RELATIVE for ._discovery/._transport, which DO get
|
|
28
|
+
# _sync/ twins and must bind to THEIR sibling twins (see _transport.py's header for why).
|
|
29
|
+
from memyauth._config import ResolvedConfig
|
|
30
|
+
from memyauth._errors import ConfigError, ProtocolError, RefreshRejectedError, TokenExchangeError, UserInfoError
|
|
31
|
+
from memyauth._pkce import generate_code_challenge, generate_code_verifier, random_nonce, random_state
|
|
32
|
+
from memyauth._types import AuthorizationRequest, OidcTokenResponse, RandomSource, UserInfo
|
|
33
|
+
|
|
34
|
+
from ._discovery import DiscoveryService
|
|
35
|
+
from ._transport import Transport, read_json_or_none, truncated_text
|
|
36
|
+
|
|
37
|
+
__all__ = ["OidcService"]
|
|
38
|
+
|
|
39
|
+
_AUTH_STANDARD_KEYS = frozenset(
|
|
40
|
+
{"response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "state", "nonce", "prompt"}
|
|
41
|
+
)
|
|
42
|
+
_LOGOUT_STANDARD_KEYS = frozenset({"post_logout_redirect_uri", "id_token_hint", "client_id", "state"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OidcService:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
config: ResolvedConfig,
|
|
50
|
+
discovery: DiscoveryService,
|
|
51
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
52
|
+
random: Optional[RandomSource] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._config = config
|
|
55
|
+
self._discovery = discovery
|
|
56
|
+
self._random: RandomSource = random or config["random"]
|
|
57
|
+
self._transport = Transport(
|
|
58
|
+
timeout_ms=config.get("discovery_timeout_ms", 10000),
|
|
59
|
+
max_retries=config["retry"]["max_retries"],
|
|
60
|
+
base_delay_ms=config["retry"]["base_delay_ms"],
|
|
61
|
+
retryable_statuses=config["retry"]["retryable_statuses"],
|
|
62
|
+
transport=transport,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def build_authorization_url(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
redirect_uri: Optional[str] = None,
|
|
69
|
+
scopes: Optional[list[str]] = None,
|
|
70
|
+
state: Optional[str] = None,
|
|
71
|
+
nonce: Optional[str] = None,
|
|
72
|
+
code_verifier: Optional[str] = None,
|
|
73
|
+
prompt: Optional[str] = None,
|
|
74
|
+
return_to: Optional[str] = None,
|
|
75
|
+
extra_params: Optional[dict[str, str]] = None,
|
|
76
|
+
) -> AuthorizationRequest:
|
|
77
|
+
# Validated BEFORE any network call (sdk-design.md §C.2) — mirrors js/src/oidc.ts:143,
|
|
78
|
+
# where `assertNoReservedCollision` runs ahead of `discovery.discover()`. A misconfigured
|
|
79
|
+
# caller must fail deterministically without spending a discovery round trip or generating
|
|
80
|
+
# provider traffic.
|
|
81
|
+
_assert_no_extra_param_collision(extra_params, _AUTH_STANDARD_KEYS)
|
|
82
|
+
doc = self._discovery.discover()
|
|
83
|
+
|
|
84
|
+
effective_redirect_uri = redirect_uri or self._config["redirect_uri"]
|
|
85
|
+
if not effective_redirect_uri:
|
|
86
|
+
raise ConfigError("redirectUri or publicBaseUrl must be configured")
|
|
87
|
+
|
|
88
|
+
cv = code_verifier or generate_code_verifier(self._random)
|
|
89
|
+
challenge = generate_code_challenge(cv)
|
|
90
|
+
effective_state = state or random_state(self._random)
|
|
91
|
+
effective_nonce = nonce or random_nonce(self._random)
|
|
92
|
+
|
|
93
|
+
base_scopes = scopes if scopes is not None else self._config["scopes"]
|
|
94
|
+
scopes_supported = doc.get("scopes_supported")
|
|
95
|
+
if isinstance(scopes_supported, list) and scopes_supported:
|
|
96
|
+
negotiated = [s for s in base_scopes if s == "openid" or s in scopes_supported]
|
|
97
|
+
else:
|
|
98
|
+
negotiated = list(base_scopes)
|
|
99
|
+
scope = " ".join(negotiated)
|
|
100
|
+
|
|
101
|
+
params: dict[str, str] = {
|
|
102
|
+
"response_type": "code",
|
|
103
|
+
"client_id": self._config["client_id"],
|
|
104
|
+
"redirect_uri": effective_redirect_uri,
|
|
105
|
+
"code_challenge": challenge,
|
|
106
|
+
"code_challenge_method": "S256",
|
|
107
|
+
"scope": scope,
|
|
108
|
+
"state": effective_state,
|
|
109
|
+
"nonce": effective_nonce,
|
|
110
|
+
"prompt": prompt if prompt is not None else "consent",
|
|
111
|
+
}
|
|
112
|
+
if extra_params:
|
|
113
|
+
params.update(extra_params)
|
|
114
|
+
|
|
115
|
+
url = f"{doc['authorization_endpoint']}?{urlencode(params)}"
|
|
116
|
+
return {
|
|
117
|
+
"url": url,
|
|
118
|
+
"code_verifier": cv,
|
|
119
|
+
"code_challenge": challenge,
|
|
120
|
+
"state": effective_state,
|
|
121
|
+
"nonce": effective_nonce,
|
|
122
|
+
"redirect_uri": effective_redirect_uri,
|
|
123
|
+
"scope": scope,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def _credential_form_fields(self) -> dict[str, str]:
|
|
127
|
+
if self._config["token_endpoint_auth_method"] == "client_secret_post":
|
|
128
|
+
return {"client_id": self._config["client_id"], "client_secret": self._config["client_secret"]}
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
def _credential_headers(self) -> dict[str, str]:
|
|
132
|
+
if self._config["token_endpoint_auth_method"] == "client_secret_basic":
|
|
133
|
+
raw = f"{self._config['client_id']}:{self._config['client_secret']}".encode("utf-8")
|
|
134
|
+
return {"Authorization": f"Basic {base64.b64encode(raw).decode('ascii')}"}
|
|
135
|
+
return {}
|
|
136
|
+
|
|
137
|
+
def _post_token(self, body: dict[str, str]) -> httpx.Response:
|
|
138
|
+
doc = self._discovery.discover()
|
|
139
|
+
form = {**body, **self._credential_form_fields()}
|
|
140
|
+
headers = self._credential_headers()
|
|
141
|
+
return self._transport.send("POST", doc["token_endpoint"], form=form, headers=headers)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _parse_token_response(resp: httpx.Response) -> OidcTokenResponse:
|
|
145
|
+
body = read_json_or_none(resp)
|
|
146
|
+
if not isinstance(body, dict) or "token_type" not in body or "expires_in" not in body:
|
|
147
|
+
raise ProtocolError("OIDC token response is missing token_type or expires_in")
|
|
148
|
+
return body # type: ignore[return-value]
|
|
149
|
+
|
|
150
|
+
def exchange_code(
|
|
151
|
+
self, *, code: str, code_verifier: str, redirect_uri: Optional[str] = None
|
|
152
|
+
) -> OidcTokenResponse:
|
|
153
|
+
body = {
|
|
154
|
+
"grant_type": "authorization_code",
|
|
155
|
+
"code": code,
|
|
156
|
+
"redirect_uri": redirect_uri or self._config["redirect_uri"],
|
|
157
|
+
"code_verifier": code_verifier,
|
|
158
|
+
}
|
|
159
|
+
resp = self._post_token(body)
|
|
160
|
+
if not (200 <= resp.status_code < 300):
|
|
161
|
+
raise TokenExchangeError(
|
|
162
|
+
f"OIDC token exchange failed: {resp.status_code}", detail=truncated_text(resp)
|
|
163
|
+
)
|
|
164
|
+
return self._parse_token_response(resp)
|
|
165
|
+
|
|
166
|
+
def refresh_tokens(self, refresh_token: str) -> OidcTokenResponse:
|
|
167
|
+
body = {"grant_type": "refresh_token", "refresh_token": refresh_token}
|
|
168
|
+
resp = self._post_token(body)
|
|
169
|
+
if not (200 <= resp.status_code < 300):
|
|
170
|
+
raise RefreshRejectedError(
|
|
171
|
+
f"OIDC token refresh rejected: {resp.status_code}", detail=truncated_text(resp)
|
|
172
|
+
)
|
|
173
|
+
return self._parse_token_response(resp)
|
|
174
|
+
|
|
175
|
+
def fetch_user_info(self, access_token: str) -> UserInfo:
|
|
176
|
+
doc = self._discovery.discover()
|
|
177
|
+
resp = self._transport.send(
|
|
178
|
+
"GET",
|
|
179
|
+
doc["userinfo_endpoint"],
|
|
180
|
+
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
|
|
181
|
+
)
|
|
182
|
+
if not (200 <= resp.status_code < 300):
|
|
183
|
+
raise UserInfoError(f"OIDC userinfo failed: {resp.status_code}", detail=truncated_text(resp))
|
|
184
|
+
body = read_json_or_none(resp)
|
|
185
|
+
if not isinstance(body, dict) or not isinstance(body.get("sub"), str) or not body["sub"]:
|
|
186
|
+
raise ProtocolError("OIDC userinfo response is missing sub")
|
|
187
|
+
return body # type: ignore[return-value]
|
|
188
|
+
|
|
189
|
+
def build_logout_url(
|
|
190
|
+
self,
|
|
191
|
+
*,
|
|
192
|
+
post_logout_redirect_uri: Optional[str] = None,
|
|
193
|
+
id_token_hint: Optional[str] = None,
|
|
194
|
+
client_id: Optional[str] = None,
|
|
195
|
+
state: Optional[str] = None,
|
|
196
|
+
extra_params: Optional[dict[str, str]] = None,
|
|
197
|
+
) -> str:
|
|
198
|
+
doc = self._discovery.discover()
|
|
199
|
+
endpoint = doc.get("end_session_endpoint") or f"{doc['issuer'].rstrip('/')}/session/end"
|
|
200
|
+
|
|
201
|
+
# NOTE: unlike build_authorization_url, client_id is NEVER silently defaulted from config
|
|
202
|
+
# here — only an EXPLICITLY passed value is emitted. Memybase's own `buildLogoutUrl()`
|
|
203
|
+
# (the estate's shipped behavior, oidc.ts:222-225) takes no arguments and returns the bare
|
|
204
|
+
# endpoint with no query string at all; the frozen invariant "no arguments and no
|
|
205
|
+
# configured postLogoutRedirectUri -> bare endpoint, no ?" (conformance case
|
|
206
|
+
# `logout-url-bare-endpoint`) is only satisfiable if every param, client_id included, is
|
|
207
|
+
# caller-opt-in.
|
|
208
|
+
params: dict[str, str] = {}
|
|
209
|
+
effective_plr = post_logout_redirect_uri or self._config.get("post_logout_redirect_uri")
|
|
210
|
+
if effective_plr:
|
|
211
|
+
params["post_logout_redirect_uri"] = effective_plr
|
|
212
|
+
if id_token_hint:
|
|
213
|
+
params["id_token_hint"] = id_token_hint
|
|
214
|
+
if client_id:
|
|
215
|
+
params["client_id"] = client_id
|
|
216
|
+
if state:
|
|
217
|
+
params["state"] = state
|
|
218
|
+
_merge_extra_params(params, extra_params, _LOGOUT_STANDARD_KEYS)
|
|
219
|
+
|
|
220
|
+
if not params:
|
|
221
|
+
return endpoint
|
|
222
|
+
return f"{endpoint}?{urlencode(params)}"
|
|
223
|
+
|
|
224
|
+
def close(self) -> None:
|
|
225
|
+
self._transport.close()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _assert_no_extra_param_collision(extra: Optional[dict[str, str]], standard_keys: frozenset) -> None:
|
|
229
|
+
if not extra:
|
|
230
|
+
return
|
|
231
|
+
for key in extra:
|
|
232
|
+
if key in standard_keys:
|
|
233
|
+
raise ConfigError(f"extraParams cannot override a standard parameter: {key}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _merge_extra_params(params: dict[str, str], extra: Optional[dict[str, str]], standard_keys: frozenset) -> None:
|
|
237
|
+
_assert_no_extra_param_collision(extra, standard_keys)
|
|
238
|
+
if extra:
|
|
239
|
+
params.update(extra)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# GENERATED by scripts/gen_sync.py from memyauth/_transport.py — DO NOT EDIT.
|
|
2
|
+
# Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
|
|
3
|
+
"""
|
|
4
|
+
@fileoverview Shared async HTTP transport — timeout, GET/HEAD-only retry, JSON/form encoding.
|
|
5
|
+
@module memyauth._transport
|
|
6
|
+
@description [async -> _sync/_transport.py twin via scripts/gen_sync.py]. Used by discovery, oidc,
|
|
7
|
+
iam and idtoken (§C.11). NOT bound to a single base_url — discovery/oidc/idtoken/iam each call
|
|
8
|
+
different absolute origins (issuer, internal issuer, iam_base_url), so every call site passes a
|
|
9
|
+
full URL. The retry policy is narrowed from the memybase pattern by §0 C13: an OAuth
|
|
10
|
+
authorization code is single-use, so `POST` (and any other non-GET/HEAD method) is NEVER
|
|
11
|
+
retried — not on status, not on timeout, not on a network error. A `Retry-After` response
|
|
12
|
+
header REPLACES (never stacks on) the computed exponential backoff for that iteration, clamped
|
|
13
|
+
to 60s. With `base_delay_ms=0` the backoff is exactly 0 regardless of jitter, which is how the
|
|
14
|
+
conformance harness pins determinism.
|
|
15
|
+
|
|
16
|
+
`send()` is deliberately low-level: it applies the retry policy and returns the raw
|
|
17
|
+
`httpx.Response` for ANY status (only a genuine transport failure raises NetworkError /
|
|
18
|
+
TimeoutError here). Each capability decides how to turn a non-2xx response into its OWN typed
|
|
19
|
+
error, because the estate's endpoints do NOT share one error envelope: `/iam/me` follows
|
|
20
|
+
openapi.yaml's `{code,message,detail}` shape (decoded via `decode_error`, §H), while OIDC-native
|
|
21
|
+
endpoints (discovery, token, userinfo) do not — they get bespoke wrapping
|
|
22
|
+
(DiscoveryError/TokenExchangeError/RefreshRejectedError/UserInfoError) carrying a truncated raw
|
|
23
|
+
body as `detail`, exactly as memybase's `oidc.ts` does. `request_json()` is the IAM-only
|
|
24
|
+
convenience that DOES raise via `decode_error` for a non-2xx response.
|
|
25
|
+
@dependencies httpx
|
|
26
|
+
@relatedFiles _errors.py (decode_error/NetworkError/TimeoutError/ProtocolError), _config.py
|
|
27
|
+
(RetryConfig), _discovery.py, _oidc.py, _iam.py, _idtoken.py, tests/test_transport.py
|
|
28
|
+
@created 2026-09-06
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import time
|
|
33
|
+
import random as _random_module
|
|
34
|
+
from typing import Any, Optional
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
|
|
38
|
+
# ABSOLUTE import of the shared/pure module: unasync generates memyauth/_sync/_transport.py,
|
|
39
|
+
# whose relative `from ._errors` would resolve to the nonexistent memyauth._sync._errors — the
|
|
40
|
+
# absolute path makes both the async module and its sync twin bind the SAME single _errors module
|
|
41
|
+
# (the exact hazard memybase's own _transport.py documents at the same import line).
|
|
42
|
+
from memyauth._errors import NetworkError, ProtocolError, TimeoutError, decode_error
|
|
43
|
+
|
|
44
|
+
__all__ = ["Transport"]
|
|
45
|
+
|
|
46
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD"})
|
|
47
|
+
_BASE_READ_RETRY = frozenset({429, 503})
|
|
48
|
+
_MAX_BACKOFF_MS = 30000
|
|
49
|
+
_MAX_RETRY_AFTER_MS = 60000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Transport:
|
|
53
|
+
"""Internal HTTP transport: header merge, timeout, GET/HEAD-only retry. One instance per
|
|
54
|
+
capability service (each may have its own timeout), all optionally sharing the same injected
|
|
55
|
+
`httpx.BaseTransport` test seam."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
timeout_ms: int = 10000,
|
|
61
|
+
max_retries: int = 2,
|
|
62
|
+
base_delay_ms: int = 500,
|
|
63
|
+
retryable_statuses: Optional[list[int]] = None,
|
|
64
|
+
headers: Optional[dict[str, str]] = None,
|
|
65
|
+
user_agent: Optional[str] = None,
|
|
66
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._timeout_ms = timeout_ms
|
|
69
|
+
self._max_retries = max_retries
|
|
70
|
+
self._base_delay_ms = base_delay_ms
|
|
71
|
+
self._extra_retryable_statuses = frozenset(retryable_statuses or [])
|
|
72
|
+
self._extra_headers = dict(headers or {})
|
|
73
|
+
self._user_agent = user_agent
|
|
74
|
+
self._transport = transport
|
|
75
|
+
self._client: Optional[httpx.Client] = None
|
|
76
|
+
|
|
77
|
+
def _get_client(self) -> httpx.Client:
|
|
78
|
+
if self._client is None or self._client.is_closed:
|
|
79
|
+
headers = {"Accept": "application/json", **self._extra_headers}
|
|
80
|
+
if self._user_agent:
|
|
81
|
+
headers["User-Agent"] = self._user_agent
|
|
82
|
+
kwargs: dict[str, Any] = {"headers": headers}
|
|
83
|
+
if self._transport is not None:
|
|
84
|
+
kwargs["transport"] = self._transport
|
|
85
|
+
self._client = httpx.Client(**kwargs)
|
|
86
|
+
return self._client
|
|
87
|
+
|
|
88
|
+
def send(
|
|
89
|
+
self,
|
|
90
|
+
method: str,
|
|
91
|
+
url: str,
|
|
92
|
+
*,
|
|
93
|
+
headers: Optional[dict[str, str]] = None,
|
|
94
|
+
json_body: Any = None,
|
|
95
|
+
form: Optional[dict[str, str]] = None,
|
|
96
|
+
params: Optional[dict[str, str]] = None,
|
|
97
|
+
timeout_ms: Optional[int] = None,
|
|
98
|
+
) -> httpx.Response:
|
|
99
|
+
"""Apply the retry policy and return the raw response for ANY terminal status. Raises
|
|
100
|
+
only `NetworkError` / `TimeoutError` for a genuine transport-level failure."""
|
|
101
|
+
method_upper = method.upper()
|
|
102
|
+
is_safe = method_upper in _SAFE_METHODS
|
|
103
|
+
max_retries = self._max_retries if is_safe else 0
|
|
104
|
+
retryable_statuses = (_BASE_READ_RETRY | self._extra_retryable_statuses) if is_safe else frozenset()
|
|
105
|
+
effective_timeout = (timeout_ms if timeout_ms is not None else self._timeout_ms) / 1000.0
|
|
106
|
+
|
|
107
|
+
client = self._get_client()
|
|
108
|
+
attempt = 0
|
|
109
|
+
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
resp = client.request(
|
|
113
|
+
method_upper,
|
|
114
|
+
url,
|
|
115
|
+
headers=headers,
|
|
116
|
+
json=json_body,
|
|
117
|
+
data=form,
|
|
118
|
+
params=params,
|
|
119
|
+
timeout=effective_timeout,
|
|
120
|
+
)
|
|
121
|
+
except httpx.TimeoutException as exc:
|
|
122
|
+
if is_safe and attempt < max_retries:
|
|
123
|
+
time.sleep(_backoff_seconds(attempt, self._base_delay_ms))
|
|
124
|
+
attempt += 1
|
|
125
|
+
continue
|
|
126
|
+
raise TimeoutError(str(exc)) from exc
|
|
127
|
+
except httpx.HTTPError as exc:
|
|
128
|
+
raise NetworkError(str(exc)) from exc
|
|
129
|
+
|
|
130
|
+
if resp.status_code in retryable_statuses and attempt < max_retries:
|
|
131
|
+
retry_after_ms = _parse_retry_after_ms(resp.headers.get("retry-after"))
|
|
132
|
+
delay_seconds = (
|
|
133
|
+
min(retry_after_ms, _MAX_RETRY_AFTER_MS) / 1000.0
|
|
134
|
+
if retry_after_ms is not None
|
|
135
|
+
else _backoff_seconds(attempt, self._base_delay_ms)
|
|
136
|
+
)
|
|
137
|
+
time.sleep(delay_seconds)
|
|
138
|
+
attempt += 1
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
return resp
|
|
142
|
+
|
|
143
|
+
def request_json(
|
|
144
|
+
self,
|
|
145
|
+
method: str,
|
|
146
|
+
url: str,
|
|
147
|
+
*,
|
|
148
|
+
headers: Optional[dict[str, str]] = None,
|
|
149
|
+
json_body: Any = None,
|
|
150
|
+
form: Optional[dict[str, str]] = None,
|
|
151
|
+
params: Optional[dict[str, str]] = None,
|
|
152
|
+
timeout_ms: Optional[int] = None,
|
|
153
|
+
) -> Any:
|
|
154
|
+
"""IAM-only convenience: `send()` + auto-raise via `decode_error` on non-2xx (the openapi
|
|
155
|
+
`{code,message,detail}` envelope). 204 / empty 2xx body -> `None`."""
|
|
156
|
+
resp = self.send(
|
|
157
|
+
method,
|
|
158
|
+
url,
|
|
159
|
+
headers=headers,
|
|
160
|
+
json_body=json_body,
|
|
161
|
+
form=form,
|
|
162
|
+
params=params,
|
|
163
|
+
timeout_ms=timeout_ms,
|
|
164
|
+
)
|
|
165
|
+
if resp.status_code == 204:
|
|
166
|
+
return None
|
|
167
|
+
if 200 <= resp.status_code < 300:
|
|
168
|
+
return read_json_or_none(resp)
|
|
169
|
+
raise decode_error(
|
|
170
|
+
resp.status_code,
|
|
171
|
+
body=read_json_or_none(resp),
|
|
172
|
+
request_id=resp.headers.get("x-request-id"),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def close(self) -> None:
|
|
176
|
+
if self._client is not None:
|
|
177
|
+
self._client.close()
|
|
178
|
+
self._client = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def read_json_or_none(resp: httpx.Response) -> Any:
|
|
182
|
+
"""2xx body -> parsed JSON, or `None` when empty. Raises `ProtocolError` on non-JSON content
|
|
183
|
+
(a 2xx body that fails to parse is a protocol violation, not an absent body)."""
|
|
184
|
+
if not resp.content:
|
|
185
|
+
return None
|
|
186
|
+
try:
|
|
187
|
+
return resp.json()
|
|
188
|
+
except ValueError as exc:
|
|
189
|
+
raise ProtocolError("expected a JSON response body") from exc
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def read_json_or_none_lenient(resp: httpx.Response) -> Any:
|
|
193
|
+
"""Like `read_json_or_none`, but never raises — used when building an error `detail` where a
|
|
194
|
+
non-JSON body is expected and should not itself trigger a different failure."""
|
|
195
|
+
try:
|
|
196
|
+
return resp.json() if resp.content else None
|
|
197
|
+
except ValueError:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def truncated_text(resp: httpx.Response, limit: int = 200) -> str:
|
|
202
|
+
"""First `limit` chars of the raw body, for an error `detail` — memybase truncates identically
|
|
203
|
+
before logging (§C.3)."""
|
|
204
|
+
try:
|
|
205
|
+
text = resp.text
|
|
206
|
+
except Exception:
|
|
207
|
+
return ""
|
|
208
|
+
return text[:limit]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _backoff_seconds(attempt: int, base_delay_ms: int) -> float:
|
|
212
|
+
computed = min(_MAX_BACKOFF_MS, base_delay_ms * (2**attempt))
|
|
213
|
+
jitter = _random_module.uniform(0, base_delay_ms) if base_delay_ms else 0
|
|
214
|
+
return (computed + jitter) / 1000.0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _parse_retry_after_ms(value: Optional[str]) -> Optional[int]:
|
|
218
|
+
if not value:
|
|
219
|
+
return None
|
|
220
|
+
try:
|
|
221
|
+
return max(0, int(float(value) * 1000))
|
|
222
|
+
except ValueError:
|
|
223
|
+
return None
|
memyauth/_transport.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview Shared async HTTP transport — timeout, GET/HEAD-only retry, JSON/form encoding.
|
|
3
|
+
@module memyauth._transport
|
|
4
|
+
@description [async -> _sync/_transport.py twin via scripts/gen_sync.py]. Used by discovery, oidc,
|
|
5
|
+
iam and idtoken (§C.11). NOT bound to a single base_url — discovery/oidc/idtoken/iam each call
|
|
6
|
+
different absolute origins (issuer, internal issuer, iam_base_url), so every call site passes a
|
|
7
|
+
full URL. The retry policy is narrowed from the memybase pattern by §0 C13: an OAuth
|
|
8
|
+
authorization code is single-use, so `POST` (and any other non-GET/HEAD method) is NEVER
|
|
9
|
+
retried — not on status, not on timeout, not on a network error. A `Retry-After` response
|
|
10
|
+
header REPLACES (never stacks on) the computed exponential backoff for that iteration, clamped
|
|
11
|
+
to 60s. With `base_delay_ms=0` the backoff is exactly 0 regardless of jitter, which is how the
|
|
12
|
+
conformance harness pins determinism.
|
|
13
|
+
|
|
14
|
+
`send()` is deliberately low-level: it applies the retry policy and returns the raw
|
|
15
|
+
`httpx.Response` for ANY status (only a genuine transport failure raises NetworkError /
|
|
16
|
+
TimeoutError here). Each capability decides how to turn a non-2xx response into its OWN typed
|
|
17
|
+
error, because the estate's endpoints do NOT share one error envelope: `/iam/me` follows
|
|
18
|
+
openapi.yaml's `{code,message,detail}` shape (decoded via `decode_error`, §H), while OIDC-native
|
|
19
|
+
endpoints (discovery, token, userinfo) do not — they get bespoke wrapping
|
|
20
|
+
(DiscoveryError/TokenExchangeError/RefreshRejectedError/UserInfoError) carrying a truncated raw
|
|
21
|
+
body as `detail`, exactly as memybase's `oidc.ts` does. `request_json()` is the IAM-only
|
|
22
|
+
convenience that DOES raise via `decode_error` for a non-2xx response.
|
|
23
|
+
@dependencies httpx
|
|
24
|
+
@relatedFiles _errors.py (decode_error/NetworkError/TimeoutError/ProtocolError), _config.py
|
|
25
|
+
(RetryConfig), _discovery.py, _oidc.py, _iam.py, _idtoken.py, tests/test_transport.py
|
|
26
|
+
@created 2026-09-06
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import random as _random_module
|
|
32
|
+
from typing import Any, Optional
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
# ABSOLUTE import of the shared/pure module: unasync generates memyauth/_sync/_transport.py,
|
|
37
|
+
# whose relative `from ._errors` would resolve to the nonexistent memyauth._sync._errors — the
|
|
38
|
+
# absolute path makes both the async module and its sync twin bind the SAME single _errors module
|
|
39
|
+
# (the exact hazard memybase's own _transport.py documents at the same import line).
|
|
40
|
+
from memyauth._errors import NetworkError, ProtocolError, TimeoutError, decode_error
|
|
41
|
+
|
|
42
|
+
__all__ = ["Transport"]
|
|
43
|
+
|
|
44
|
+
_SAFE_METHODS = frozenset({"GET", "HEAD"})
|
|
45
|
+
_BASE_READ_RETRY = frozenset({429, 503})
|
|
46
|
+
_MAX_BACKOFF_MS = 30000
|
|
47
|
+
_MAX_RETRY_AFTER_MS = 60000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Transport:
|
|
51
|
+
"""Internal HTTP transport: header merge, timeout, GET/HEAD-only retry. One instance per
|
|
52
|
+
capability service (each may have its own timeout), all optionally sharing the same injected
|
|
53
|
+
`httpx.BaseTransport` test seam."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
timeout_ms: int = 10000,
|
|
59
|
+
max_retries: int = 2,
|
|
60
|
+
base_delay_ms: int = 500,
|
|
61
|
+
retryable_statuses: Optional[list[int]] = None,
|
|
62
|
+
headers: Optional[dict[str, str]] = None,
|
|
63
|
+
user_agent: Optional[str] = None,
|
|
64
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._timeout_ms = timeout_ms
|
|
67
|
+
self._max_retries = max_retries
|
|
68
|
+
self._base_delay_ms = base_delay_ms
|
|
69
|
+
self._extra_retryable_statuses = frozenset(retryable_statuses or [])
|
|
70
|
+
self._extra_headers = dict(headers or {})
|
|
71
|
+
self._user_agent = user_agent
|
|
72
|
+
self._transport = transport
|
|
73
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
74
|
+
|
|
75
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
76
|
+
if self._client is None or self._client.is_closed:
|
|
77
|
+
headers = {"Accept": "application/json", **self._extra_headers}
|
|
78
|
+
if self._user_agent:
|
|
79
|
+
headers["User-Agent"] = self._user_agent
|
|
80
|
+
kwargs: dict[str, Any] = {"headers": headers}
|
|
81
|
+
if self._transport is not None:
|
|
82
|
+
kwargs["transport"] = self._transport
|
|
83
|
+
self._client = httpx.AsyncClient(**kwargs)
|
|
84
|
+
return self._client
|
|
85
|
+
|
|
86
|
+
async def send(
|
|
87
|
+
self,
|
|
88
|
+
method: str,
|
|
89
|
+
url: str,
|
|
90
|
+
*,
|
|
91
|
+
headers: Optional[dict[str, str]] = None,
|
|
92
|
+
json_body: Any = None,
|
|
93
|
+
form: Optional[dict[str, str]] = None,
|
|
94
|
+
params: Optional[dict[str, str]] = None,
|
|
95
|
+
timeout_ms: Optional[int] = None,
|
|
96
|
+
) -> httpx.Response:
|
|
97
|
+
"""Apply the retry policy and return the raw response for ANY terminal status. Raises
|
|
98
|
+
only `NetworkError` / `TimeoutError` for a genuine transport-level failure."""
|
|
99
|
+
method_upper = method.upper()
|
|
100
|
+
is_safe = method_upper in _SAFE_METHODS
|
|
101
|
+
max_retries = self._max_retries if is_safe else 0
|
|
102
|
+
retryable_statuses = (_BASE_READ_RETRY | self._extra_retryable_statuses) if is_safe else frozenset()
|
|
103
|
+
effective_timeout = (timeout_ms if timeout_ms is not None else self._timeout_ms) / 1000.0
|
|
104
|
+
|
|
105
|
+
client = self._get_client()
|
|
106
|
+
attempt = 0
|
|
107
|
+
|
|
108
|
+
while True:
|
|
109
|
+
try:
|
|
110
|
+
resp = await client.request(
|
|
111
|
+
method_upper,
|
|
112
|
+
url,
|
|
113
|
+
headers=headers,
|
|
114
|
+
json=json_body,
|
|
115
|
+
data=form,
|
|
116
|
+
params=params,
|
|
117
|
+
timeout=effective_timeout,
|
|
118
|
+
)
|
|
119
|
+
except httpx.TimeoutException as exc:
|
|
120
|
+
if is_safe and attempt < max_retries:
|
|
121
|
+
await asyncio.sleep(_backoff_seconds(attempt, self._base_delay_ms))
|
|
122
|
+
attempt += 1
|
|
123
|
+
continue
|
|
124
|
+
raise TimeoutError(str(exc)) from exc
|
|
125
|
+
except httpx.HTTPError as exc:
|
|
126
|
+
raise NetworkError(str(exc)) from exc
|
|
127
|
+
|
|
128
|
+
if resp.status_code in retryable_statuses and attempt < max_retries:
|
|
129
|
+
retry_after_ms = _parse_retry_after_ms(resp.headers.get("retry-after"))
|
|
130
|
+
delay_seconds = (
|
|
131
|
+
min(retry_after_ms, _MAX_RETRY_AFTER_MS) / 1000.0
|
|
132
|
+
if retry_after_ms is not None
|
|
133
|
+
else _backoff_seconds(attempt, self._base_delay_ms)
|
|
134
|
+
)
|
|
135
|
+
await asyncio.sleep(delay_seconds)
|
|
136
|
+
attempt += 1
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
return resp
|
|
140
|
+
|
|
141
|
+
async def request_json(
|
|
142
|
+
self,
|
|
143
|
+
method: str,
|
|
144
|
+
url: str,
|
|
145
|
+
*,
|
|
146
|
+
headers: Optional[dict[str, str]] = None,
|
|
147
|
+
json_body: Any = None,
|
|
148
|
+
form: Optional[dict[str, str]] = None,
|
|
149
|
+
params: Optional[dict[str, str]] = None,
|
|
150
|
+
timeout_ms: Optional[int] = None,
|
|
151
|
+
) -> Any:
|
|
152
|
+
"""IAM-only convenience: `send()` + auto-raise via `decode_error` on non-2xx (the openapi
|
|
153
|
+
`{code,message,detail}` envelope). 204 / empty 2xx body -> `None`."""
|
|
154
|
+
resp = await self.send(
|
|
155
|
+
method,
|
|
156
|
+
url,
|
|
157
|
+
headers=headers,
|
|
158
|
+
json_body=json_body,
|
|
159
|
+
form=form,
|
|
160
|
+
params=params,
|
|
161
|
+
timeout_ms=timeout_ms,
|
|
162
|
+
)
|
|
163
|
+
if resp.status_code == 204:
|
|
164
|
+
return None
|
|
165
|
+
if 200 <= resp.status_code < 300:
|
|
166
|
+
return read_json_or_none(resp)
|
|
167
|
+
raise decode_error(
|
|
168
|
+
resp.status_code,
|
|
169
|
+
body=read_json_or_none(resp),
|
|
170
|
+
request_id=resp.headers.get("x-request-id"),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
async def close(self) -> None:
|
|
174
|
+
if self._client is not None:
|
|
175
|
+
await self._client.aclose()
|
|
176
|
+
self._client = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def read_json_or_none(resp: httpx.Response) -> Any:
|
|
180
|
+
"""2xx body -> parsed JSON, or `None` when empty. Raises `ProtocolError` on non-JSON content
|
|
181
|
+
(a 2xx body that fails to parse is a protocol violation, not an absent body)."""
|
|
182
|
+
if not resp.content:
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
return resp.json()
|
|
186
|
+
except ValueError as exc:
|
|
187
|
+
raise ProtocolError("expected a JSON response body") from exc
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def read_json_or_none_lenient(resp: httpx.Response) -> Any:
|
|
191
|
+
"""Like `read_json_or_none`, but never raises — used when building an error `detail` where a
|
|
192
|
+
non-JSON body is expected and should not itself trigger a different failure."""
|
|
193
|
+
try:
|
|
194
|
+
return resp.json() if resp.content else None
|
|
195
|
+
except ValueError:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def truncated_text(resp: httpx.Response, limit: int = 200) -> str:
|
|
200
|
+
"""First `limit` chars of the raw body, for an error `detail` — memybase truncates identically
|
|
201
|
+
before logging (§C.3)."""
|
|
202
|
+
try:
|
|
203
|
+
text = resp.text
|
|
204
|
+
except Exception:
|
|
205
|
+
return ""
|
|
206
|
+
return text[:limit]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _backoff_seconds(attempt: int, base_delay_ms: int) -> float:
|
|
210
|
+
computed = min(_MAX_BACKOFF_MS, base_delay_ms * (2**attempt))
|
|
211
|
+
jitter = _random_module.uniform(0, base_delay_ms) if base_delay_ms else 0
|
|
212
|
+
return (computed + jitter) / 1000.0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _parse_retry_after_ms(value: Optional[str]) -> Optional[int]:
|
|
216
|
+
if not value:
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
return max(0, int(float(value) * 1000))
|
|
220
|
+
except ValueError:
|
|
221
|
+
return None
|