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/_config.py ADDED
@@ -0,0 +1,407 @@
1
+ """
2
+ @fileoverview `resolve_config()` — validates and defaults every `MemyAuth` constructor field.
3
+ @module memyauth._config
4
+ @description Pure — no I/O. The single source of `ConfigError` in this SDK (design §F). Mirrors
5
+ the JS `resolveConfig()` field-for-field, default-for-default, message-for-message so the two
6
+ languages can be exercised by the same conformance case and produce the same failure. Two
7
+ points are load-bearing:
8
+ 1. `id_token_validation` has NO DEFAULT (§C.16) — omitting it is itself a `ConfigError`, because
9
+ the estate genuinely runs both trust strategies in production and a default would be silently
10
+ wrong for someone.
11
+ 2. An `allowed_algorithms` pin that cannot verify any token this provider issues raises
12
+ `UnsupportedAlgorithmPinError` HERE, at construction, before any network call — this is the
13
+ loud-failure mechanism that makes the twice-shipped RS256-vs-ES384 defect (roboson, memyvet)
14
+ structurally unrepeatable (§C.10.2, test R5).
15
+ Capability-specific requirements (an IAM caller credential, a session signing secret) are
16
+ deliberately NOT enforced here — they are enforced lazily, at first use, per §F's validation
17
+ timing rule, so an IAM-only consumer never needs a session secret.
18
+ @dependencies typing, re, logging, urllib.parse (stdlib only); httpx (type-check only, optional)
19
+ @relatedFiles _errors.py (ConfigError/UnsupportedAlgorithmPinError), _roles.py (DEFAULT_ROLE_MAP),
20
+ _state.py (COOKIE_NAME_RE), _client.py (calls resolve_config in __init__),
21
+ tests/test_config.py, tests/test_contract_parity.py (drift-checks the hand-mirrored constants
22
+ below against ../../contract/contract.json)
23
+ @created 2026-09-06
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import re
29
+ from typing import Any, Optional, TypedDict
30
+ from urllib.parse import urlsplit
31
+
32
+ from ._errors import ConfigError, UnsupportedAlgorithmPinError
33
+ from ._pkce import SystemRandomSource
34
+ from ._roles import DEFAULT_ROLE_MAP
35
+ from ._state import COOKIE_NAME_RE
36
+ from ._types import Clock, RandomSource, SystemClock
37
+
38
+ __all__ = [
39
+ "PROVIDER_SIGNING_ALGORITHMS",
40
+ "DESCRIPTOR_SIGNING_ALGORITHMS",
41
+ "DEFAULT_SCOPES",
42
+ "RetryConfig",
43
+ "ResolvedConfig",
44
+ "resolve_config",
45
+ ]
46
+
47
+ _log = logging.getLogger("memyauth")
48
+
49
+ # Hand-mirrored from contract.json's security.signingAlgorithm (the provider's SOLE signing
50
+ # algorithm) — drift-checked at test time by tests/test_contract_parity.py. There is no
51
+ # sync-contract.mjs equivalent for Python; this constant is the one place a Python consumer of
52
+ # this SDK reads it from.
53
+ PROVIDER_SIGNING_ALGORITHMS: tuple[str, ...] = ("ES384",)
54
+
55
+ # The descriptor schema's full `verification.allowedAlgorithms` enum (contracts/identity/
56
+ # oidc-consumer-request-v1.schema.json) — what a consumer's PIN may legally contain, not what the
57
+ # provider actually issues.
58
+ DESCRIPTOR_SIGNING_ALGORITHMS: tuple[str, ...] = ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512")
59
+
60
+ DEFAULT_SCOPES: tuple[str, ...] = ("openid", "profile", "email")
61
+ _DEFAULT_CALLBACK_PATH = "/api/auth/oidc/callback"
62
+ _DEFAULT_IAM_BASE_URL = "http://memyauth-service.memydev-auth.svc.cluster.local"
63
+ _DEFAULT_SESSION_ISSUER = "memydev-auth-sdk"
64
+ _DEFAULT_STATE_COOKIE_NAME = "memyauth_state"
65
+ _TOKEN_ENDPOINT_AUTH_METHODS = ("client_secret_post", "client_secret_basic")
66
+ _ID_TOKEN_VALIDATION_MODES = ("local-jwks", "tls-token-endpoint")
67
+ _IAM_CALLER_MODES = ("auto", "appiam", "appidp", "m2m")
68
+
69
+ _ABSOLUTE_URL_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*://\S+$")
70
+
71
+
72
+ class RetryConfig(TypedDict, total=False):
73
+ max_retries: int
74
+ base_delay_ms: int
75
+ retryable_statuses: list[int]
76
+
77
+
78
+ class ResolvedConfig(TypedDict, total=False):
79
+ issuer_url: str
80
+ client_id: str
81
+ client_secret: str
82
+ internal_issuer_url: Optional[str]
83
+ public_base_url: Optional[str]
84
+ callback_path: str
85
+ redirect_uri: str
86
+ post_logout_redirect_uri: Optional[str]
87
+ scopes: list[str]
88
+ token_endpoint_auth_method: str
89
+ id_token_validation: str
90
+ allowed_algorithms: list[str]
91
+ acknowledge_unverifiable_algorithm_pin: bool
92
+ strict_unknown_fields: bool
93
+ iam_base_url: str
94
+ iam_app_id: Optional[str]
95
+ iam_app_secret: Optional[str]
96
+ iam_m2m_token: Optional[str]
97
+ iam_caller_mode: str
98
+ iam_timeout_ms: int
99
+ discovery_timeout_ms: int
100
+ retry: RetryConfig
101
+ session_signing_secret: Optional[str]
102
+ access_ttl_seconds: int
103
+ refresh_ttl_seconds: int
104
+ session_issuer: str
105
+ state_signing_secret: Optional[str]
106
+ state_ttl_seconds: int
107
+ state_cookie_name: str
108
+ role_map: dict[str, str]
109
+ role_overrides: dict[str, str]
110
+ default_role: str
111
+ global_admin_roles: list[str]
112
+ headers: dict[str, str]
113
+ transport: Any
114
+ clock: Clock
115
+ random: RandomSource
116
+
117
+
118
+ def _strip_trailing_slashes(value: str) -> str:
119
+ return re.sub(r"/+$", "", value)
120
+
121
+
122
+ def _is_absolute_http_url(value: str) -> bool:
123
+ try:
124
+ parts = urlsplit(value)
125
+ except ValueError:
126
+ return False
127
+ return parts.scheme in ("http", "https") and bool(parts.netloc)
128
+
129
+
130
+ def _validate_absolute_http_url(value: Optional[str], field: str) -> Optional[str]:
131
+ if value is None:
132
+ return None
133
+ if not isinstance(value, str) or not _is_absolute_http_url(value):
134
+ raise ConfigError(f"{field} must be an absolute http(s) URL")
135
+ return _strip_trailing_slashes(value)
136
+
137
+
138
+ def _validate_absolute_url(value: Optional[str], field: str) -> Optional[str]:
139
+ if value is None:
140
+ return None
141
+ if not isinstance(value, str) or not _ABSOLUTE_URL_RE.match(value):
142
+ raise ConfigError(f"{field} must be an absolute URL")
143
+ return value
144
+
145
+
146
+ def _require_non_empty_str(value: Any, field: str, message: Optional[str] = None) -> str:
147
+ if not isinstance(value, str) or not value:
148
+ raise ConfigError(message or f"{field} is required")
149
+ return value
150
+
151
+
152
+ def resolve_config(**kwargs: Any) -> ResolvedConfig:
153
+ cfg: dict[str, Any] = {}
154
+
155
+ # --- required identity fields -----------------------------------------------------------
156
+ cfg["issuer_url"] = _validate_absolute_http_url(kwargs.get("issuer_url"), "issuerUrl")
157
+ if cfg["issuer_url"] is None:
158
+ raise ConfigError("issuerUrl must be an absolute http(s) URL")
159
+
160
+ cfg["client_id"] = _require_non_empty_str(kwargs.get("client_id"), "clientId", "clientId is required")
161
+ cfg["client_secret"] = _require_non_empty_str(
162
+ kwargs.get("client_secret"), "clientSecret", "clientSecret is required"
163
+ )
164
+
165
+ # --- URLs ---------------------------------------------------------------------------------
166
+ cfg["internal_issuer_url"] = _validate_absolute_http_url(kwargs.get("internal_issuer_url"), "internalIssuerUrl")
167
+ cfg["public_base_url"] = _validate_absolute_http_url(kwargs.get("public_base_url"), "publicBaseUrl")
168
+
169
+ callback_path = kwargs.get("callback_path", _DEFAULT_CALLBACK_PATH)
170
+ if not isinstance(callback_path, str) or not callback_path.startswith("/"):
171
+ raise ConfigError("callbackPath must start with /")
172
+ cfg["callback_path"] = callback_path
173
+
174
+ redirect_uri = kwargs.get("redirect_uri")
175
+ if redirect_uri is not None:
176
+ cfg["redirect_uri"] = _validate_absolute_url(redirect_uri, "redirectUri")
177
+ elif cfg["public_base_url"]:
178
+ cfg["redirect_uri"] = cfg["public_base_url"] + callback_path
179
+ else:
180
+ raise ConfigError("redirectUri or publicBaseUrl must be configured")
181
+
182
+ cfg["post_logout_redirect_uri"] = _validate_absolute_url(
183
+ kwargs.get("post_logout_redirect_uri"), "postLogoutRedirectUri"
184
+ )
185
+
186
+ # --- scopes ---------------------------------------------------------------------------------
187
+ scopes = kwargs.get("scopes")
188
+ if scopes is None:
189
+ scopes = list(DEFAULT_SCOPES)
190
+ if not isinstance(scopes, (list, tuple)) or len(scopes) == 0 or "openid" not in scopes:
191
+ raise ConfigError('scopes must include "openid"')
192
+ cfg["scopes"] = list(scopes)
193
+
194
+ # --- token endpoint auth method ---------------------------------------------------------
195
+ token_endpoint_auth_method = kwargs.get("token_endpoint_auth_method", "client_secret_post")
196
+ if token_endpoint_auth_method not in _TOKEN_ENDPOINT_AUTH_METHODS:
197
+ raise ConfigError("tokenEndpointAuthMethod must be client_secret_post or client_secret_basic")
198
+ cfg["token_endpoint_auth_method"] = token_endpoint_auth_method
199
+
200
+ # --- ID-token trust strategy (§C.16 — required, no default) ----------------------------
201
+ id_token_validation = kwargs.get("id_token_validation")
202
+ if id_token_validation is None:
203
+ raise ConfigError(
204
+ "idTokenValidation is required — choose local-jwks (recommended; verifies the ID "
205
+ "token signature against provider JWKS) or tls-token-endpoint (no local verification; "
206
+ "identity comes from userinfo or /iam/me). Both are in production use in this estate, "
207
+ "so there is deliberately no default."
208
+ )
209
+ if id_token_validation not in _ID_TOKEN_VALIDATION_MODES:
210
+ raise ConfigError("idTokenValidation must be local-jwks or tls-token-endpoint")
211
+ cfg["id_token_validation"] = id_token_validation
212
+
213
+ allowed_algorithms = kwargs.get("allowed_algorithms")
214
+ if allowed_algorithms is None:
215
+ allowed_algorithms = ["ES384"] if id_token_validation == "local-jwks" else []
216
+ allowed_algorithms = list(allowed_algorithms)
217
+ for alg in allowed_algorithms:
218
+ if alg not in DESCRIPTOR_SIGNING_ALGORITHMS:
219
+ raise ConfigError(f"allowedAlgorithms contains an unsupported algorithm: {alg}")
220
+ if id_token_validation == "tls-token-endpoint" and allowed_algorithms:
221
+ raise ConfigError("allowedAlgorithms must be empty when idTokenValidation is tls-token-endpoint")
222
+ if id_token_validation == "local-jwks" and not allowed_algorithms:
223
+ raise ConfigError("allowedAlgorithms must not be empty when idTokenValidation is local-jwks")
224
+
225
+ acknowledge_pin = bool(kwargs.get("acknowledge_unverifiable_algorithm_pin", False))
226
+ if id_token_validation == "local-jwks":
227
+ intersects = bool(set(allowed_algorithms) & set(PROVIDER_SIGNING_ALGORITHMS))
228
+ if not intersects:
229
+ if not acknowledge_pin:
230
+ raise UnsupportedAlgorithmPinError(
231
+ f"allowedAlgorithms {allowed_algorithms!r} cannot verify any token this "
232
+ "provider issues. MemyAuth signs ID tokens with ES384 and nothing else, so "
233
+ "this pin refuses every genuine token and sign-in fails closed for every "
234
+ "user. Fix the pin, or set acknowledgeUnverifiableAlgorithmPin: true if you "
235
+ "are deliberately targeting a different provider."
236
+ )
237
+ _log.warning(
238
+ "memyauth: allowedAlgorithms %r cannot verify any token this provider issues; "
239
+ "continuing because acknowledgeUnverifiableAlgorithmPin=True was set explicitly.",
240
+ allowed_algorithms,
241
+ )
242
+ elif acknowledge_pin:
243
+ raise ConfigError(
244
+ "acknowledgeUnverifiableAlgorithmPin is set but allowedAlgorithms can verify "
245
+ "this provider's tokens — remove it"
246
+ )
247
+ cfg["allowed_algorithms"] = allowed_algorithms
248
+ cfg["acknowledge_unverifiable_algorithm_pin"] = acknowledge_pin
249
+ cfg["strict_unknown_fields"] = bool(kwargs.get("strict_unknown_fields", False))
250
+
251
+ # --- IAM ------------------------------------------------------------------------------------
252
+ cfg["iam_base_url"] = _validate_absolute_http_url(
253
+ kwargs.get("iam_base_url", _DEFAULT_IAM_BASE_URL), "iamBaseUrl"
254
+ )
255
+
256
+ iam_app_id = kwargs.get("iam_app_id")
257
+ if iam_app_id is not None and (not isinstance(iam_app_id, str) or not iam_app_id):
258
+ raise ConfigError("iamAppId must be a non-empty string")
259
+ cfg["iam_app_id"] = iam_app_id
260
+
261
+ iam_app_secret = kwargs.get("iam_app_secret")
262
+ if iam_app_secret is not None:
263
+ if not isinstance(iam_app_secret, str) or not iam_app_secret:
264
+ raise ConfigError("iamAppSecret requires iamAppId")
265
+ if not iam_app_id:
266
+ raise ConfigError("iamAppSecret requires iamAppId")
267
+ cfg["iam_app_secret"] = iam_app_secret
268
+
269
+ iam_m2m_token = kwargs.get("iam_m2m_token")
270
+ if iam_m2m_token is not None and (not isinstance(iam_m2m_token, str) or not iam_m2m_token):
271
+ raise ConfigError("iamM2mToken must be a non-empty string")
272
+ cfg["iam_m2m_token"] = iam_m2m_token
273
+
274
+ iam_caller_mode = kwargs.get("iam_caller_mode", "auto")
275
+ if iam_caller_mode not in _IAM_CALLER_MODES:
276
+ raise ConfigError("iamCallerMode must be one of auto, appiam, appidp, m2m")
277
+ cfg["iam_caller_mode"] = iam_caller_mode
278
+
279
+ iam_timeout_ms = kwargs.get("iam_timeout_ms", 10000)
280
+ if not isinstance(iam_timeout_ms, int) or isinstance(iam_timeout_ms, bool) or not (1 <= iam_timeout_ms <= 120000):
281
+ raise ConfigError("iamTimeoutMs must be an integer between 1 and 120000")
282
+ cfg["iam_timeout_ms"] = iam_timeout_ms
283
+
284
+ discovery_timeout_ms = kwargs.get("discovery_timeout_ms", iam_timeout_ms)
285
+ if (
286
+ not isinstance(discovery_timeout_ms, int)
287
+ or isinstance(discovery_timeout_ms, bool)
288
+ or not (1 <= discovery_timeout_ms <= 120000)
289
+ ):
290
+ raise ConfigError("discoveryTimeoutMs must be an integer between 1 and 120000")
291
+ cfg["discovery_timeout_ms"] = discovery_timeout_ms
292
+
293
+ # --- retry ----------------------------------------------------------------------------------
294
+ retry_in = kwargs.get("retry") or {}
295
+ max_retries = retry_in.get("max_retries", 2)
296
+ if not isinstance(max_retries, int) or isinstance(max_retries, bool) or not (0 <= max_retries <= 10):
297
+ raise ConfigError("retry.maxRetries must be an integer between 0 and 10")
298
+ base_delay_ms = retry_in.get("base_delay_ms", 500)
299
+ if not isinstance(base_delay_ms, int) or isinstance(base_delay_ms, bool) or not (0 <= base_delay_ms <= 10000):
300
+ raise ConfigError("retry.baseDelayMs must be an integer between 0 and 10000")
301
+ retryable_statuses = list(retry_in.get("retryable_statuses", []))
302
+ for status in retryable_statuses:
303
+ if not isinstance(status, int) or isinstance(status, bool) or not (400 <= status <= 599):
304
+ raise ConfigError("retry.retryableStatuses must contain integers between 400 and 599")
305
+ cfg["retry"] = {
306
+ "max_retries": max_retries,
307
+ "base_delay_ms": base_delay_ms,
308
+ "retryable_statuses": retryable_statuses,
309
+ }
310
+
311
+ # --- session tokens ---------------------------------------------------------------------
312
+ session_signing_secret = kwargs.get("session_signing_secret")
313
+ if session_signing_secret is not None and len(session_signing_secret) < 32:
314
+ raise ConfigError("sessionSigningSecret must be at least 32 characters")
315
+ cfg["session_signing_secret"] = session_signing_secret
316
+
317
+ access_ttl_seconds = kwargs.get("access_ttl_seconds", 14400)
318
+ if not isinstance(access_ttl_seconds, int) or isinstance(access_ttl_seconds, bool) or access_ttl_seconds < 60:
319
+ raise ConfigError("accessTtlSeconds must be an integer >= 60")
320
+ cfg["access_ttl_seconds"] = access_ttl_seconds
321
+
322
+ refresh_ttl_seconds = kwargs.get("refresh_ttl_seconds", 7776000)
323
+ if (
324
+ not isinstance(refresh_ttl_seconds, int)
325
+ or isinstance(refresh_ttl_seconds, bool)
326
+ or refresh_ttl_seconds < access_ttl_seconds
327
+ ):
328
+ raise ConfigError("refreshTtlSeconds must be an integer >= accessTtlSeconds")
329
+ cfg["refresh_ttl_seconds"] = refresh_ttl_seconds
330
+
331
+ session_issuer = kwargs.get("session_issuer", _DEFAULT_SESSION_ISSUER)
332
+ if not isinstance(session_issuer, str) or not session_issuer:
333
+ raise ConfigError("sessionIssuer must be a non-empty string")
334
+ cfg["session_issuer"] = session_issuer
335
+
336
+ # --- state cookie -----------------------------------------------------------------------
337
+ state_signing_secret = kwargs.get("state_signing_secret", session_signing_secret)
338
+ if state_signing_secret is not None and len(state_signing_secret) < 32:
339
+ raise ConfigError("stateSigningSecret must be at least 32 characters")
340
+ cfg["state_signing_secret"] = state_signing_secret
341
+
342
+ state_ttl_seconds = kwargs.get("state_ttl_seconds", 300)
343
+ if (
344
+ not isinstance(state_ttl_seconds, int)
345
+ or isinstance(state_ttl_seconds, bool)
346
+ or not (30 <= state_ttl_seconds <= 3600)
347
+ ):
348
+ raise ConfigError("stateTtlSeconds must be an integer between 30 and 3600")
349
+ cfg["state_ttl_seconds"] = state_ttl_seconds
350
+
351
+ state_cookie_name = kwargs.get("state_cookie_name", _DEFAULT_STATE_COOKIE_NAME)
352
+ if not isinstance(state_cookie_name, str) or not COOKIE_NAME_RE.match(state_cookie_name):
353
+ raise ConfigError("stateCookieName is not a valid cookie name")
354
+ cfg["state_cookie_name"] = state_cookie_name
355
+
356
+ # --- role mapping -----------------------------------------------------------------------
357
+ role_map = kwargs.get("role_map", DEFAULT_ROLE_MAP)
358
+ for value in role_map.values():
359
+ if not isinstance(value, str) or not value:
360
+ raise ConfigError("roleMap values must be non-empty strings")
361
+ cfg["role_map"] = dict(role_map)
362
+
363
+ role_overrides = kwargs.get("role_overrides", {})
364
+ for value in role_overrides.values():
365
+ if not isinstance(value, str) or not value:
366
+ raise ConfigError("roleOverrides values must be non-empty strings")
367
+ cfg["role_overrides"] = dict(role_overrides)
368
+
369
+ default_role = kwargs.get("default_role", "member")
370
+ if not isinstance(default_role, str) or not default_role:
371
+ raise ConfigError("defaultRole must be a non-empty string")
372
+ cfg["default_role"] = default_role
373
+
374
+ global_admin_roles = kwargs.get("global_admin_roles", ["admin"])
375
+ if not global_admin_roles or any(not isinstance(r, str) or not r for r in global_admin_roles):
376
+ raise ConfigError("globalAdminRoles must be a non-empty array of role names")
377
+ cfg["global_admin_roles"] = list(global_admin_roles)
378
+
379
+ # --- misc / seams -----------------------------------------------------------------------
380
+ headers = kwargs.get("headers", {})
381
+ for value in headers.values():
382
+ if not isinstance(value, str):
383
+ raise ConfigError("headers values must be strings")
384
+ cfg["headers"] = dict(headers)
385
+
386
+ transport = kwargs.get("transport")
387
+ if transport is not None:
388
+ try:
389
+ import httpx
390
+
391
+ if not isinstance(transport, httpx.BaseTransport):
392
+ raise ConfigError("transport must be an httpx.BaseTransport")
393
+ except ImportError: # pragma: no cover - httpx is a hard runtime dependency
394
+ pass
395
+ cfg["transport"] = transport
396
+
397
+ clock = kwargs.get("clock")
398
+ if clock is not None and not callable(getattr(clock, "now", None)):
399
+ raise ConfigError("clock must expose now()")
400
+ cfg["clock"] = clock or SystemClock()
401
+
402
+ random_source = kwargs.get("random")
403
+ if random_source is not None and not callable(getattr(random_source, "bytes", None)):
404
+ raise ConfigError("random must expose bytes(n)")
405
+ cfg["random"] = random_source or SystemRandomSource()
406
+
407
+ return cfg # type: ignore[return-value]
memyauth/_discovery.py ADDED
@@ -0,0 +1,172 @@
1
+ """
2
+ @fileoverview Capability 1 — OIDC discovery: dual-origin fetch, memoize-on-success, escape guard.
3
+ @module memyauth._discovery
4
+ @description [async -> _sync/_discovery.py twin via scripts/gen_sync.py]. Ported verbatim from
5
+ `memybase/src/auth/oidc.ts:89-153`. Two invariants are load-bearing:
6
+ 1. MEMOIZE ON SUCCESS ONLY — a failed fetch must NOT be cached, or one transient provider blip
7
+ disables every future login until process restart. An `asyncio.Lock` serializes concurrent
8
+ callers so only one fetch happens even under a login stampede.
9
+ 2. DUAL-ORIGIN SAFETY — server-side endpoints (token/userinfo/jwks) must resolve to the SAME
10
+ origin as the internal fetch issuer; a server-side endpoint that escapes to a different
11
+ origin is `DiscoveryEscapeError`, not silently followed. Browser-facing endpoints
12
+ (authorization, end_session) are then rewritten to the PUBLIC origin, preserving
13
+ pathname+search — a server calling the internal Service DNS name must still hand the
14
+ browser a URL it can actually reach.
15
+ @dependencies re, asyncio, urllib.parse (stdlib); ._transport
16
+ @relatedFiles _transport.py (Transport.send), _errors.py (DiscoveryError/IssuerMismatchError/
17
+ DiscoveryEscapeError/ProtocolError), _oidc.py and _idtoken.py (both consume
18
+ `discovery.discover()`), tests/test_discovery.py
19
+ @created 2026-09-06
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import re
25
+ from datetime import datetime, timezone
26
+ from typing import Optional
27
+ from urllib.parse import urlsplit
28
+
29
+ import httpx
30
+
31
+ # ABSOLUTE imports for the shared/pure modules (see _transport.py's header for why); RELATIVE
32
+ # import for ._transport, which DOES get a _sync/ twin and must bind to ITS sibling twin.
33
+ from memyauth._errors import DiscoveryEscapeError, DiscoveryError, IssuerMismatchError, ProtocolError
34
+ from memyauth._types import Clock, DiscoveryHealth, OidcDiscoveryDocument, SystemClock
35
+
36
+ from ._transport import Transport
37
+
38
+ __all__ = ["DiscoveryService"]
39
+
40
+ _REQUIRED_KEYS = ("issuer", "authorization_endpoint", "token_endpoint", "userinfo_endpoint", "jwks_uri")
41
+ _ABS_HTTP_RE = re.compile(r"^https?://")
42
+ _DEFAULT_TIMEOUT_MS = 10000
43
+
44
+
45
+ def _strip_trailing_slashes(value: str) -> str:
46
+ return re.sub(r"/+$", "", value)
47
+
48
+
49
+ def _origin(url: str) -> str:
50
+ parts = urlsplit(url)
51
+ return f"{parts.scheme}://{parts.netloc}"
52
+
53
+
54
+ def _rewrite_origin(url: str, new_origin: str) -> str:
55
+ parts = urlsplit(url)
56
+ tail = parts.path
57
+ if parts.query:
58
+ tail += f"?{parts.query}"
59
+ return new_origin + tail
60
+
61
+
62
+ def _iso_utc_ms(epoch_ms: int) -> str:
63
+ dt = datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc)
64
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{epoch_ms % 1000:03d}Z"
65
+
66
+
67
+ class DiscoveryService:
68
+ def __init__(
69
+ self,
70
+ *,
71
+ issuer_url: str,
72
+ internal_issuer_url: Optional[str] = None,
73
+ timeout_ms: Optional[int] = None,
74
+ transport: Optional[httpx.BaseTransport] = None,
75
+ clock: Optional[Clock] = None,
76
+ ) -> None:
77
+ self._public_issuer = _strip_trailing_slashes(issuer_url)
78
+ self._fetch_issuer = _strip_trailing_slashes(internal_issuer_url or issuer_url)
79
+ self._clock: Clock = clock or SystemClock()
80
+ self._transport = Transport(timeout_ms=timeout_ms or _DEFAULT_TIMEOUT_MS, transport=transport)
81
+ # A hand-rolled busy-wait mutex, NOT `asyncio.Lock` — deliberately. `scripts/gen_sync.py`
82
+ # renames every bare `asyncio` NAME token to `time` (so `asyncio.sleep` -> `time.sleep`,
83
+ # the substitution the retry backoff in _transport.py wants); `asyncio.Lock` would become
84
+ # the nonexistent `time.Lock` in the generated sync twin. `asyncio.sleep(0)` survives the
85
+ # rename as `time.sleep(0)` — a harmless no-op in the sync twin, where a single call stack
86
+ # can never re-enter this method concurrently in the first place. In the async original,
87
+ # yielding via `sleep(0)` is a standard (if humble) cooperative spin-lock: correct under
88
+ # asyncio's single-threaded scheduler, and adequate for a rarely-contended, boot-time-ish
89
+ # call like discovery.
90
+ self._locked = False
91
+ self._cached: Optional[OidcDiscoveryDocument] = None
92
+ self._last_success_at: Optional[str] = None
93
+ self._last_error: Optional[str] = None
94
+
95
+ async def discover(self) -> OidcDiscoveryDocument:
96
+ while self._locked:
97
+ await asyncio.sleep(0)
98
+ if self._cached is not None:
99
+ return self._cached
100
+ self._locked = True
101
+ try:
102
+ doc = await self._fetch()
103
+ except Exception as exc: # noqa: BLE001 - deliberately broad: every failure clears the memo
104
+ self._last_error = str(exc)
105
+ raise
106
+ finally:
107
+ self._locked = False
108
+ self._cached = doc
109
+ self._last_success_at = _iso_utc_ms(self._clock.now())
110
+ self._last_error = None
111
+ return doc
112
+
113
+ async def _fetch(self) -> OidcDiscoveryDocument:
114
+ url = f"{self._fetch_issuer}/.well-known/openid-configuration"
115
+ resp = await self._transport.send("GET", url)
116
+ if not (200 <= resp.status_code < 300):
117
+ raise DiscoveryError(f"OIDC discovery failed: {resp.status_code}")
118
+ try:
119
+ doc = resp.json()
120
+ except ValueError as exc:
121
+ raise ProtocolError("OIDC discovery response was not JSON") from exc
122
+ if not isinstance(doc, dict):
123
+ raise ProtocolError("OIDC discovery response was not a JSON object")
124
+
125
+ for key in _REQUIRED_KEYS:
126
+ value = doc.get(key)
127
+ if not isinstance(value, str) or not _ABS_HTTP_RE.match(value):
128
+ raise DiscoveryError(f"OIDC discovery is missing {key}")
129
+
130
+ if _strip_trailing_slashes(doc["issuer"]) != self._public_issuer:
131
+ raise IssuerMismatchError(
132
+ f"OIDC discovery issuer mismatch: expected {self._public_issuer}, got {doc['issuer']}"
133
+ )
134
+
135
+ if self._fetch_issuer != self._public_issuer:
136
+ fetch_origin = _origin(self._fetch_issuer)
137
+ for key in ("token_endpoint", "userinfo_endpoint", "jwks_uri"):
138
+ if _origin(doc[key]) != fetch_origin:
139
+ raise DiscoveryEscapeError(
140
+ f"OIDC discovery {key} escaped the internal origin: {doc[key]}"
141
+ )
142
+ public_origin = _origin(self._public_issuer)
143
+ doc["authorization_endpoint"] = _rewrite_origin(doc["authorization_endpoint"], public_origin)
144
+ end_session = doc.get("end_session_endpoint")
145
+ if isinstance(end_session, str):
146
+ doc["end_session_endpoint"] = _rewrite_origin(end_session, public_origin)
147
+
148
+ return doc
149
+
150
+ async def warm(self) -> None:
151
+ """Boot-time readiness warm-up. Never throws."""
152
+ try:
153
+ await self.discover()
154
+ except Exception: # noqa: BLE001 - intentional: warm() is a best-effort prefetch
155
+ pass
156
+
157
+ def health(self) -> DiscoveryHealth:
158
+ """Never fetches — reports the last terminal outcome only."""
159
+ if self._cached is not None:
160
+ state = "ok"
161
+ elif self._last_error is not None:
162
+ state = "failed"
163
+ else:
164
+ state = "unknown"
165
+ return {"state": state, "last_success_at": self._last_success_at, "last_error": self._last_error}
166
+
167
+ def reset(self) -> None:
168
+ """Drops the memo so the next `discover()` call fetches again."""
169
+ self._cached = None
170
+
171
+ async def close(self) -> None:
172
+ await self._transport.close()