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/_session.py ADDED
@@ -0,0 +1,149 @@
1
+ """
2
+ @fileoverview SDK-owned HS256 session tokens — access + refresh, structurally separate from the
3
+ provider's ES384 OIDC tokens.
4
+ @module memyauth._session
5
+ @description SYNCHRONOUS (PyJWT is sync) — a deliberate, documented asymmetry with the JS SDK's
6
+ `async` session methods (jose's `SignJWT.sign()`/`jwtVerify()` return promises). Because this
7
+ module has no `async`, it is a SHARED PURE module with no `_sync/` twin: both
8
+ `memyauth.SessionTokenService` and `memyauth.sync.SessionTokenService` import the exact same
9
+ class object (§C.7). The separation from provider tokens is enforced SIX ways — see the class
10
+ docstring — because a consumer that once confuses "a token I signed" with "a token MemyAuth
11
+ signed" has a forgeable-identity bug, not a cosmetic one.
12
+ @dependencies pyjwt[crypto]
13
+ @relatedFiles _types.py (SessionAccessClaims/SessionRefreshClaims/Clock), _errors.py
14
+ (ConfigError/SessionTokenError), _idtoken.py (the OTHER verifier — provider tokens only),
15
+ tests/test_session.py
16
+ @created 2026-09-06
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Optional
21
+
22
+ import jwt
23
+
24
+ from ._errors import ConfigError, SessionTokenError
25
+ from ._types import Clock, SessionAccessClaims, SessionAccessInput, SessionRefreshClaims, SessionRefreshInput, SystemClock
26
+
27
+ __all__ = ["SessionTokenService"]
28
+
29
+ _MIN_SECRET_LENGTH = 32
30
+ _DEFAULT_ACCESS_TTL_SECONDS = 14400 # 4h
31
+ _DEFAULT_REFRESH_TTL_SECONDS = 7776000 # 90d
32
+ _DEFAULT_ISSUER = "memydev-auth-sdk"
33
+ _ALGORITHM = "HS256"
34
+
35
+
36
+ class SessionTokenService:
37
+ """Issues and verifies the SDK's own HS256 session-token family.
38
+
39
+ The separation from provider (ES384) tokens is enforced SIX ways (§C.7 — this ranking
40
+ outranks any naming a goal-prompt might request elsewhere):
41
+
42
+ 1. Separate class — `IdTokenVerifier` (see `_idtoken.py`) is the only class that touches
43
+ provider tokens; neither class can do the other's job.
44
+ 2. Separate secret field — `signing_secret` (symmetric, >= 32 chars) is never read by any
45
+ provider-token path; the provider's `issuer_url`/JWKS is never read here.
46
+ 3. Mandatory `type` discriminator (`'access'` / `'refresh'`) — verification rejects the wrong
47
+ value.
48
+ 4. Mandatory `iss` claim — SDK-issued tokens carry `iss = issuer` (default
49
+ `'memydev-auth-sdk'`); a provider token's `iss = https://auth.memy.dev/oidc` fails this
50
+ check before any crypto matters.
51
+ 5. Algorithm pin — verification passes `algorithms=['HS256']` only; an ES384 token (the
52
+ provider's family) is rejected on `alg` alone.
53
+ 6. Naming — every exported symbol carries `Session`; the plan's bare `AccessClaims` /
54
+ `RefreshClaims` names are never exported under any alias.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ signing_secret: str,
61
+ access_ttl_seconds: int = _DEFAULT_ACCESS_TTL_SECONDS,
62
+ refresh_ttl_seconds: int = _DEFAULT_REFRESH_TTL_SECONDS,
63
+ issuer: str = _DEFAULT_ISSUER,
64
+ clock: Optional[Clock] = None,
65
+ ) -> None:
66
+ if not signing_secret or len(signing_secret) < _MIN_SECRET_LENGTH:
67
+ raise ConfigError(f"sessionSigningSecret must be at least {_MIN_SECRET_LENGTH} characters")
68
+ if refresh_ttl_seconds < access_ttl_seconds:
69
+ raise ConfigError("refreshTtlSeconds must be an integer >= accessTtlSeconds")
70
+
71
+ self._secret = signing_secret
72
+ self._access_ttl = access_ttl_seconds
73
+ self._refresh_ttl = refresh_ttl_seconds
74
+ self._issuer = issuer
75
+ self._clock: Clock = clock or SystemClock()
76
+
77
+ def _iat(self) -> int:
78
+ return self._clock.now() // 1000
79
+
80
+ def issue_access_token(self, claims: SessionAccessInput) -> str:
81
+ iat = self._iat()
82
+ payload: dict[str, Any] = {
83
+ **claims,
84
+ "type": "access",
85
+ "iss": self._issuer,
86
+ "iat": iat,
87
+ "exp": iat + self._access_ttl,
88
+ }
89
+ return jwt.encode(payload, self._secret, algorithm=_ALGORITHM)
90
+
91
+ def issue_refresh_token(self, claims: SessionRefreshInput) -> str:
92
+ iat = self._iat()
93
+ payload: dict[str, Any] = {
94
+ **claims,
95
+ "type": "refresh",
96
+ "iss": self._issuer,
97
+ "iat": iat,
98
+ "exp": iat + self._refresh_ttl,
99
+ }
100
+ return jwt.encode(payload, self._secret, algorithm=_ALGORITHM)
101
+
102
+ def _decode(self, token: str) -> dict[str, Any]:
103
+ try:
104
+ # `verify_exp`/`verify_iat` are disabled here and re-checked manually below against
105
+ # the INJECTED clock, not PyJWT's wall-clock read — otherwise a token minted under a
106
+ # fixed test clock (or any clock skewed from the real host clock) would read as
107
+ # already-expired to PyJWT regardless of the ttl actually configured.
108
+ payload = jwt.decode(
109
+ token,
110
+ self._secret,
111
+ algorithms=[_ALGORITHM],
112
+ issuer=self._issuer,
113
+ options={"verify_exp": False, "verify_iat": False, "require": ["exp", "iat", "iss", "type"]},
114
+ )
115
+ except jwt.PyJWTError as exc:
116
+ # The message NEVER echoes the token — PyJWT's own exception text does not include it.
117
+ raise SessionTokenError(f"session token verification failed: {exc}") from exc
118
+
119
+ now_seconds = self._clock.now() / 1000
120
+ exp = payload.get("exp")
121
+ if not isinstance(exp, (int, float)) or now_seconds > exp:
122
+ raise SessionTokenError("session token verification failed: token has expired")
123
+ return payload
124
+
125
+ def verify_access_token(self, token: str) -> SessionAccessClaims:
126
+ payload = self._decode(token)
127
+ if payload.get("type") != "access":
128
+ raise SessionTokenError("session token verification failed: wrong type (expected access)")
129
+ return payload # type: ignore[return-value]
130
+
131
+ def verify_refresh_token(self, token: str) -> SessionRefreshClaims:
132
+ payload = self._decode(token)
133
+ if payload.get("type") != "refresh":
134
+ raise SessionTokenError("session token verification failed: wrong type (expected refresh)")
135
+ return payload # type: ignore[return-value]
136
+
137
+ def is_session_token(self, token: str) -> bool:
138
+ """Recognize an SDK-signed token WITHOUT accepting it as unexpired authorization.
139
+ Verifies the signature only (never `exp`); returns False on any failure — never throws."""
140
+ try:
141
+ payload = jwt.decode(
142
+ token,
143
+ self._secret,
144
+ algorithms=[_ALGORITHM],
145
+ options={"verify_exp": False, "verify_iat": False},
146
+ )
147
+ except jwt.PyJWTError:
148
+ return False
149
+ return payload.get("type") in ("access", "refresh") and payload.get("iss") == self._issuer
memyauth/_state.py ADDED
@@ -0,0 +1,139 @@
1
+ """
2
+ @fileoverview HS256 state cookie — PKCE/nonce/state carrier and the actual CSRF check.
3
+ @module memyauth._state
4
+ @description SYNCHRONOUS (same PyJWT asymmetry as `_session.py` — shared pure module, no `_sync/`
5
+ twin). Claim keys (`cv`/`n`/`rt`/`st`) are ported VERBATIM from
6
+ `memybase/src/auth/oidc.ts:227-235` so a cookie signed by memybase stays readable by this SDK
7
+ and vice versa. `verify()` alone only proves the cookie is ours; `verify_callback()` is the
8
+ actual CSRF defense — it constant-time-compares the callback's `state` query parameter against
9
+ the signed cookie's `st` claim (`hmac.compare_digest`), which is what the goal-prompt calls
10
+ "CSRF protection".
11
+ @dependencies pyjwt[crypto], hmac (stdlib)
12
+ @relatedFiles _session.py (the sibling HS256-but-different-purpose service), _oidc.py
13
+ (build_authorization_url mints the state/nonce this cookie carries), _config.py (state_*
14
+ field validation), tests/test_state.py
15
+ @created 2026-09-06
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import hmac
20
+ import re
21
+ from typing import Any, Optional
22
+
23
+ import jwt
24
+
25
+ from ._errors import ConfigError, StateCookieError, StateMismatchError
26
+ from ._types import Clock, StateCookieInput, StateCookiePayload, SystemClock
27
+
28
+ __all__ = ["StateCookieService", "COOKIE_NAME_RE"]
29
+
30
+ _MIN_SECRET_LENGTH = 32
31
+ _DEFAULT_TTL_SECONDS = 300
32
+ _DEFAULT_COOKIE_NAME = "memyauth_state"
33
+ _ALGORITHM = "HS256"
34
+ _MIN_TTL = 30
35
+ _MAX_TTL = 3600
36
+
37
+ # RFC 6265 §4.1.1 cookie-name token charset (also enforced at the top-level config layer, §F).
38
+ COOKIE_NAME_RE = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$")
39
+
40
+
41
+ class StateCookieService:
42
+ def __init__(
43
+ self,
44
+ *,
45
+ signing_secret: str,
46
+ ttl_seconds: int = _DEFAULT_TTL_SECONDS,
47
+ cookie_name: str = _DEFAULT_COOKIE_NAME,
48
+ clock: Optional[Clock] = None,
49
+ ) -> None:
50
+ if not signing_secret or len(signing_secret) < _MIN_SECRET_LENGTH:
51
+ raise ConfigError(f"stateSigningSecret must be at least {_MIN_SECRET_LENGTH} characters")
52
+ if not (_MIN_TTL <= ttl_seconds <= _MAX_TTL):
53
+ raise ConfigError(f"stateTtlSeconds must be an integer between {_MIN_TTL} and {_MAX_TTL}")
54
+ if not cookie_name or not COOKIE_NAME_RE.match(cookie_name):
55
+ raise ConfigError("stateCookieName is not a valid cookie name")
56
+
57
+ self._secret = signing_secret
58
+ self._ttl = ttl_seconds
59
+ self._cookie_name = cookie_name
60
+ self._clock: Clock = clock or SystemClock()
61
+
62
+ @property
63
+ def cookie_name(self) -> str:
64
+ return self._cookie_name
65
+
66
+ def sign(self, payload: StateCookieInput) -> str:
67
+ iat = self._clock.now() // 1000
68
+ claims: dict[str, Any] = {
69
+ "cv": payload.get("code_verifier"),
70
+ "n": payload.get("nonce"),
71
+ "st": payload.get("state"),
72
+ "iat": iat,
73
+ "exp": iat + self._ttl,
74
+ }
75
+ return_to = payload.get("return_to")
76
+ if return_to is not None:
77
+ claims["rt"] = return_to
78
+ return jwt.encode(claims, self._secret, algorithm=_ALGORITHM)
79
+
80
+ def verify(self, token: str) -> StateCookiePayload:
81
+ try:
82
+ # `verify_exp`/`verify_iat` disabled and re-checked manually against the INJECTED
83
+ # clock — see _session.py's `_decode` for why (same determinism requirement).
84
+ payload = jwt.decode(
85
+ token,
86
+ self._secret,
87
+ algorithms=[_ALGORITHM],
88
+ options={"verify_exp": False, "verify_iat": False, "require": ["exp", "iat"]},
89
+ )
90
+ except jwt.PyJWTError as exc:
91
+ raise StateCookieError(f"state cookie verification failed: {exc}") from exc
92
+
93
+ now_seconds = self._clock.now() / 1000
94
+ exp = payload.get("exp")
95
+ if not isinstance(exp, (int, float)) or now_seconds > exp:
96
+ raise StateCookieError("state cookie verification failed: expired")
97
+
98
+ cv = payload.get("cv")
99
+ nonce = payload.get("n")
100
+ state = payload.get("st")
101
+ if not isinstance(cv, str) or not isinstance(nonce, str) or not isinstance(state, str):
102
+ raise StateCookieError("state cookie verification failed: missing cv/n/st")
103
+
104
+ return_to = payload.get("rt")
105
+ if not isinstance(return_to, str):
106
+ return_to = "/"
107
+
108
+ return {
109
+ "code_verifier": cv,
110
+ "nonce": nonce,
111
+ "state": state,
112
+ "return_to": return_to,
113
+ }
114
+
115
+ def verify_callback(self, token: str, callback_state: str) -> StateCookiePayload:
116
+ """`verify()` + a constant-time compare of the callback's `state` query parameter. THE
117
+ CSRF check — see module docstring."""
118
+ payload = self.verify(token)
119
+ if not hmac.compare_digest(payload["state"], callback_state):
120
+ raise StateMismatchError("state cookie verification failed: state mismatch")
121
+ return payload
122
+
123
+ def cookie_attributes(
124
+ self,
125
+ *,
126
+ path: str = "/",
127
+ secure: bool = True,
128
+ same_site: str = "Lax",
129
+ max_age_seconds: Optional[int] = None,
130
+ domain: Optional[str] = None,
131
+ ) -> str:
132
+ """The `Set-Cookie` attribute fragment (no `name=value`). Pure — no crypto, no clock."""
133
+ effective_max_age = self._ttl if max_age_seconds is None else max_age_seconds
134
+ parts = [f"Path={path}", f"Max-Age={effective_max_age}", "HttpOnly", f"SameSite={same_site}"]
135
+ if secure:
136
+ parts.append("Secure")
137
+ if domain:
138
+ parts.append(f"Domain={domain}")
139
+ return "; ".join(parts)
@@ -0,0 +1,3 @@
1
+ # GENERATED by scripts/gen_sync.py from memyauth/__init__.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """Generated synchronous twins of the memyauth async modules — see scripts/gen_sync.py."""
@@ -0,0 +1,171 @@
1
+ # GENERATED by scripts/gen_sync.py from memyauth/_client.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """
4
+ @fileoverview Top-level `MemyAuth` async client — composes the seven capability services.
5
+ @module memyauth._client
6
+ @description [async -> _sync/_client.py twin via scripts/gen_sync.py]. `SDK_VERSION` lives HERE
7
+ (design §G.5's six-file version-parity check reads it via
8
+ `/SDK_VERSION\\s*=\\s*["\\']([^"\\']+)["\\']/`). `.discovery`, `.oidc`, `.iam` and `.roles` are
9
+ EAGER (built in `__init__`) because their prerequisites are always present once `resolve_config`
10
+ succeeds; `.session`, `.state` and `.id_token` are LAZY properties — `.session`/`.state` raise
11
+ `ConfigError` at first access when their signing secret was never configured (an IAM-only
12
+ consumer should never need one), while `.id_token` never raises (`id_token_validation` is a
13
+ required, already-validated field, so its prerequisite is always satisfiable) but is still built
14
+ lazily and cached, purely to avoid touching `discovery` before it is needed.
15
+ @dependencies ._config, ._discovery, ._oidc, ._iam, ._idtoken, ._roles, ._session, ._state
16
+ @relatedFiles __init__.py (re-exports MemyAuth + SDK_VERSION), sync/__init__.py (the generated
17
+ sync facade), _config.py (resolve_config), tests/test_client.py
18
+ @created 2026-09-06
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import Any, Optional
23
+
24
+ # ABSOLUTE imports for the shared/pure modules; RELATIVE for the sibling async modules that DO
25
+ # get _sync/ twins, so the generated _sync/_client.py binds to ITS siblings in _sync/ (see
26
+ # _transport.py's header for why this split is required).
27
+ from memyauth._config import ResolvedConfig, resolve_config
28
+ from memyauth._errors import ConfigError
29
+ from memyauth._roles import RoleMapper
30
+ from memyauth._session import SessionTokenService
31
+ from memyauth._state import StateCookieService
32
+
33
+ from ._discovery import DiscoveryService
34
+ from ._iam import IamService
35
+ from ._idtoken import IdTokenVerifier
36
+ from ._oidc import OidcService
37
+
38
+ __all__ = ["MemyAuth", "SDK_VERSION"]
39
+
40
+ SDK_VERSION = "0.1.0"
41
+
42
+ _SECRET_FIELDS = (
43
+ "client_secret",
44
+ "iam_app_secret",
45
+ "iam_m2m_token",
46
+ "session_signing_secret",
47
+ "state_signing_secret",
48
+ )
49
+
50
+
51
+ class MemyAuth:
52
+ """Async MemyAuth client — the composition root for all nine customer capabilities.
53
+
54
+ Usage::
55
+
56
+ async with MemyAuth(
57
+ issuer_url="https://auth.memy.dev/oidc",
58
+ client_id="my-app",
59
+ client_secret="...",
60
+ public_base_url="https://my-app.example.com",
61
+ id_token_validation="local-jwks",
62
+ ) as auth:
63
+ req = await auth.oidc.build_authorization_url()
64
+ """
65
+
66
+ def __init__(self, **kwargs: Any) -> None:
67
+ self._config: ResolvedConfig = resolve_config(**kwargs)
68
+
69
+ self.discovery = DiscoveryService(
70
+ issuer_url=self._config["issuer_url"],
71
+ internal_issuer_url=self._config["internal_issuer_url"],
72
+ timeout_ms=self._config["discovery_timeout_ms"],
73
+ transport=self._config["transport"],
74
+ clock=self._config["clock"],
75
+ )
76
+ self.oidc = OidcService(
77
+ config=self._config,
78
+ discovery=self.discovery,
79
+ transport=self._config["transport"],
80
+ random=self._config["random"],
81
+ )
82
+ self.iam = IamService(config=self._config, transport=self._config["transport"])
83
+ self.roles = RoleMapper(
84
+ role_map=self._config["role_map"],
85
+ role_overrides=self._config["role_overrides"],
86
+ default_role=self._config["default_role"],
87
+ global_admin_roles=self._config["global_admin_roles"],
88
+ )
89
+
90
+ self._session: Optional[SessionTokenService] = None
91
+ self._state: Optional[StateCookieService] = None
92
+ self._id_token: Optional[IdTokenVerifier] = None
93
+
94
+ @property
95
+ def session(self) -> SessionTokenService:
96
+ if self._session is None:
97
+ secret = self._config.get("session_signing_secret")
98
+ if not secret:
99
+ raise ConfigError("sessionSigningSecret is required to issue or verify session tokens")
100
+ self._session = SessionTokenService(
101
+ signing_secret=secret,
102
+ access_ttl_seconds=self._config["access_ttl_seconds"],
103
+ refresh_ttl_seconds=self._config["refresh_ttl_seconds"],
104
+ issuer=self._config["session_issuer"],
105
+ clock=self._config["clock"],
106
+ )
107
+ return self._session
108
+
109
+ @property
110
+ def state(self) -> StateCookieService:
111
+ if self._state is None:
112
+ secret = self._config.get("state_signing_secret") or self._config.get("session_signing_secret")
113
+ if not secret:
114
+ raise ConfigError("stateSigningSecret (or sessionSigningSecret) is required to sign state cookies")
115
+ self._state = StateCookieService(
116
+ signing_secret=secret,
117
+ ttl_seconds=self._config["state_ttl_seconds"],
118
+ cookie_name=self._config["state_cookie_name"],
119
+ clock=self._config["clock"],
120
+ )
121
+ return self._state
122
+
123
+ @property
124
+ def id_token(self) -> IdTokenVerifier:
125
+ """Always available — `id_token_validation` is a required, already-validated field, so
126
+ this never raises. Still lazy + cached, purely to defer construction cost."""
127
+ if self._id_token is None:
128
+ self._id_token = IdTokenVerifier(
129
+ mode=self._config["id_token_validation"],
130
+ allowed_algorithms=self._config["allowed_algorithms"],
131
+ discovery=self.discovery,
132
+ transport=self._config["transport"],
133
+ clock=self._config["clock"],
134
+ )
135
+ return self._id_token
136
+
137
+ def resolved_config(self) -> dict[str, Any]:
138
+ """The resolved config, with every secret-bearing field replaced by `"<set>"` /
139
+ `"<unset>"` — safe to log or hand to a conformance driver."""
140
+ redacted: dict[str, Any] = dict(self._config)
141
+ redacted.pop("transport", None)
142
+ redacted.pop("clock", None)
143
+ redacted.pop("random", None)
144
+ for field in _SECRET_FIELDS:
145
+ redacted[field] = "<set>" if self._config.get(field) else "<unset>"
146
+ return redacted
147
+
148
+ def capabilities(self) -> dict[str, bool]:
149
+ """Which lazily-gated capabilities are currently usable, without constructing them."""
150
+ return {
151
+ "discovery": True,
152
+ "oidc": True,
153
+ "iam": True,
154
+ "roles": True,
155
+ "session": bool(self._config.get("session_signing_secret")),
156
+ "state": bool(self._config.get("state_signing_secret") or self._config.get("session_signing_secret")),
157
+ "id_token": True,
158
+ }
159
+
160
+ def close(self) -> None:
161
+ self.discovery.close() # type: ignore[attr-defined]
162
+ self.oidc.close() # type: ignore[attr-defined]
163
+ self.iam.close() # type: ignore[attr-defined]
164
+ if self._id_token is not None:
165
+ self._id_token.close() # type: ignore[attr-defined]
166
+
167
+ def __enter__(self) -> "MemyAuth":
168
+ return self
169
+
170
+ def __exit__(self, *exc: Any) -> None:
171
+ self.close()
@@ -0,0 +1,174 @@
1
+ # GENERATED by scripts/gen_sync.py from memyauth/_discovery.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """
4
+ @fileoverview Capability 1 — OIDC discovery: dual-origin fetch, memoize-on-success, escape guard.
5
+ @module memyauth._discovery
6
+ @description [async -> _sync/_discovery.py twin via scripts/gen_sync.py]. Ported verbatim from
7
+ `memybase/src/auth/oidc.ts:89-153`. Two invariants are load-bearing:
8
+ 1. MEMOIZE ON SUCCESS ONLY — a failed fetch must NOT be cached, or one transient provider blip
9
+ disables every future login until process restart. An `asyncio.Lock` serializes concurrent
10
+ callers so only one fetch happens even under a login stampede.
11
+ 2. DUAL-ORIGIN SAFETY — server-side endpoints (token/userinfo/jwks) must resolve to the SAME
12
+ origin as the internal fetch issuer; a server-side endpoint that escapes to a different
13
+ origin is `DiscoveryEscapeError`, not silently followed. Browser-facing endpoints
14
+ (authorization, end_session) are then rewritten to the PUBLIC origin, preserving
15
+ pathname+search — a server calling the internal Service DNS name must still hand the
16
+ browser a URL it can actually reach.
17
+ @dependencies re, asyncio, urllib.parse (stdlib); ._transport
18
+ @relatedFiles _transport.py (Transport.send), _errors.py (DiscoveryError/IssuerMismatchError/
19
+ DiscoveryEscapeError/ProtocolError), _oidc.py and _idtoken.py (both consume
20
+ `discovery.discover()`), tests/test_discovery.py
21
+ @created 2026-09-06
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import time
26
+ import re
27
+ from datetime import datetime, timezone
28
+ from typing import Optional
29
+ from urllib.parse import urlsplit
30
+
31
+ import httpx
32
+
33
+ # ABSOLUTE imports for the shared/pure modules (see _transport.py's header for why); RELATIVE
34
+ # import for ._transport, which DOES get a _sync/ twin and must bind to ITS sibling twin.
35
+ from memyauth._errors import DiscoveryEscapeError, DiscoveryError, IssuerMismatchError, ProtocolError
36
+ from memyauth._types import Clock, DiscoveryHealth, OidcDiscoveryDocument, SystemClock
37
+
38
+ from ._transport import Transport
39
+
40
+ __all__ = ["DiscoveryService"]
41
+
42
+ _REQUIRED_KEYS = ("issuer", "authorization_endpoint", "token_endpoint", "userinfo_endpoint", "jwks_uri")
43
+ _ABS_HTTP_RE = re.compile(r"^https?://")
44
+ _DEFAULT_TIMEOUT_MS = 10000
45
+
46
+
47
+ def _strip_trailing_slashes(value: str) -> str:
48
+ return re.sub(r"/+$", "", value)
49
+
50
+
51
+ def _origin(url: str) -> str:
52
+ parts = urlsplit(url)
53
+ return f"{parts.scheme}://{parts.netloc}"
54
+
55
+
56
+ def _rewrite_origin(url: str, new_origin: str) -> str:
57
+ parts = urlsplit(url)
58
+ tail = parts.path
59
+ if parts.query:
60
+ tail += f"?{parts.query}"
61
+ return new_origin + tail
62
+
63
+
64
+ def _iso_utc_ms(epoch_ms: int) -> str:
65
+ dt = datetime.fromtimestamp(epoch_ms / 1000, tz=timezone.utc)
66
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{epoch_ms % 1000:03d}Z"
67
+
68
+
69
+ class DiscoveryService:
70
+ def __init__(
71
+ self,
72
+ *,
73
+ issuer_url: str,
74
+ internal_issuer_url: Optional[str] = None,
75
+ timeout_ms: Optional[int] = None,
76
+ transport: Optional[httpx.BaseTransport] = None,
77
+ clock: Optional[Clock] = None,
78
+ ) -> None:
79
+ self._public_issuer = _strip_trailing_slashes(issuer_url)
80
+ self._fetch_issuer = _strip_trailing_slashes(internal_issuer_url or issuer_url)
81
+ self._clock: Clock = clock or SystemClock()
82
+ self._transport = Transport(timeout_ms=timeout_ms or _DEFAULT_TIMEOUT_MS, transport=transport)
83
+ # A hand-rolled busy-wait mutex, NOT `asyncio.Lock` — deliberately. `scripts/gen_sync.py`
84
+ # renames every bare `asyncio` NAME token to `time` (so `asyncio.sleep` -> `time.sleep`,
85
+ # the substitution the retry backoff in _transport.py wants); `asyncio.Lock` would become
86
+ # the nonexistent `time.Lock` in the generated sync twin. `asyncio.sleep(0)` survives the
87
+ # rename as `time.sleep(0)` — a harmless no-op in the sync twin, where a single call stack
88
+ # can never re-enter this method concurrently in the first place. In the async original,
89
+ # yielding via `sleep(0)` is a standard (if humble) cooperative spin-lock: correct under
90
+ # asyncio's single-threaded scheduler, and adequate for a rarely-contended, boot-time-ish
91
+ # call like discovery.
92
+ self._locked = False
93
+ self._cached: Optional[OidcDiscoveryDocument] = None
94
+ self._last_success_at: Optional[str] = None
95
+ self._last_error: Optional[str] = None
96
+
97
+ def discover(self) -> OidcDiscoveryDocument:
98
+ while self._locked:
99
+ time.sleep(0)
100
+ if self._cached is not None:
101
+ return self._cached
102
+ self._locked = True
103
+ try:
104
+ doc = self._fetch()
105
+ except Exception as exc: # noqa: BLE001 - deliberately broad: every failure clears the memo
106
+ self._last_error = str(exc)
107
+ raise
108
+ finally:
109
+ self._locked = False
110
+ self._cached = doc
111
+ self._last_success_at = _iso_utc_ms(self._clock.now())
112
+ self._last_error = None
113
+ return doc
114
+
115
+ def _fetch(self) -> OidcDiscoveryDocument:
116
+ url = f"{self._fetch_issuer}/.well-known/openid-configuration"
117
+ resp = self._transport.send("GET", url)
118
+ if not (200 <= resp.status_code < 300):
119
+ raise DiscoveryError(f"OIDC discovery failed: {resp.status_code}")
120
+ try:
121
+ doc = resp.json()
122
+ except ValueError as exc:
123
+ raise ProtocolError("OIDC discovery response was not JSON") from exc
124
+ if not isinstance(doc, dict):
125
+ raise ProtocolError("OIDC discovery response was not a JSON object")
126
+
127
+ for key in _REQUIRED_KEYS:
128
+ value = doc.get(key)
129
+ if not isinstance(value, str) or not _ABS_HTTP_RE.match(value):
130
+ raise DiscoveryError(f"OIDC discovery is missing {key}")
131
+
132
+ if _strip_trailing_slashes(doc["issuer"]) != self._public_issuer:
133
+ raise IssuerMismatchError(
134
+ f"OIDC discovery issuer mismatch: expected {self._public_issuer}, got {doc['issuer']}"
135
+ )
136
+
137
+ if self._fetch_issuer != self._public_issuer:
138
+ fetch_origin = _origin(self._fetch_issuer)
139
+ for key in ("token_endpoint", "userinfo_endpoint", "jwks_uri"):
140
+ if _origin(doc[key]) != fetch_origin:
141
+ raise DiscoveryEscapeError(
142
+ f"OIDC discovery {key} escaped the internal origin: {doc[key]}"
143
+ )
144
+ public_origin = _origin(self._public_issuer)
145
+ doc["authorization_endpoint"] = _rewrite_origin(doc["authorization_endpoint"], public_origin)
146
+ end_session = doc.get("end_session_endpoint")
147
+ if isinstance(end_session, str):
148
+ doc["end_session_endpoint"] = _rewrite_origin(end_session, public_origin)
149
+
150
+ return doc
151
+
152
+ def warm(self) -> None:
153
+ """Boot-time readiness warm-up. Never throws."""
154
+ try:
155
+ self.discover()
156
+ except Exception: # noqa: BLE001 - intentional: warm() is a best-effort prefetch
157
+ pass
158
+
159
+ def health(self) -> DiscoveryHealth:
160
+ """Never fetches — reports the last terminal outcome only."""
161
+ if self._cached is not None:
162
+ state = "ok"
163
+ elif self._last_error is not None:
164
+ state = "failed"
165
+ else:
166
+ state = "unknown"
167
+ return {"state": state, "last_success_at": self._last_success_at, "last_error": self._last_error}
168
+
169
+ def reset(self) -> None:
170
+ """Drops the memo so the next `discover()` call fetches again."""
171
+ self._cached = None
172
+
173
+ def close(self) -> None:
174
+ self._transport.close()