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/_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()
|