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/_sync/_iam.py ADDED
@@ -0,0 +1,300 @@
1
+ # GENERATED by scripts/gen_sync.py from memyauth/_iam.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """
4
+ @fileoverview Capability 5 — IAM identity resolver: caller-credential resolution, permissive
5
+ response validation, role projection, tenant-key redaction.
6
+ @module memyauth._iam
7
+ @description [async -> _sync/_iam.py twin via scripts/gen_sync.py]. This module is the direct
8
+ fix for the estate's single worst confirmed defect (D2): roboson's `/iam/me` model is
9
+ `extra="forbid"` and omits `organizations`, a field memyauth's IAM returns UNCONDITIONALLY, so
10
+ a real login there can 503. `assert_iam_user_context()` is the enforcement point of the
11
+ "permissive about unknown fields, strict about known ones" policy (§C.5.1) — it is a
12
+ hand-written checker, NOT a Pydantic `BaseModel`, deliberately: `ConfigDict(extra="forbid")` is
13
+ one keyword away from re-creating D2, and Pydantic is not a dependency of this SDK (zero deps
14
+ beyond httpx + PyJWT). The caller-credential resolver mirrors
15
+ `memybase/src/iam/iam-client.ts:112-126` with the header names corrected to the real ones
16
+ (`X-App-Id`/`X-App-Secret` never existed) and enforces mutual exclusion structurally: the
17
+ header builder can only ever produce ONE marker header per request (R6).
18
+ @dependencies ._transport, ._roles
19
+ @relatedFiles _roles.py (RoleMapper — mapAll/isGlobalAdmin), _config.py (ResolvedConfig, the
20
+ caller-credential fields), _errors.py, _types.py (UserContext/IamSession/IamOrgEntry),
21
+ tests/test_iam.py
22
+ @created 2026-09-06
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from typing import Any, Optional
27
+
28
+ import httpx
29
+
30
+ # ABSOLUTE imports for the shared/pure modules; RELATIVE for ._transport, which DOES get a
31
+ # _sync/ twin and must bind to ITS sibling twin (see _transport.py's header for why).
32
+ from memyauth._config import ResolvedConfig
33
+ from memyauth._errors import ConfigError, ProtocolError, UnauthorizedError
34
+ from memyauth._roles import RoleMapper
35
+ from memyauth._types import IamCallerMode, IamOrgEntry, IamSession, IamTenant, IamUserContext
36
+
37
+ from ._transport import Transport
38
+
39
+ __all__ = ["IamService", "assert_iam_user_context"]
40
+
41
+ _KNOWN_TOP_LEVEL = frozenset({"user", "tenant", "organizations", "permissions"})
42
+ _KNOWN_USER_FIELDS = frozenset(
43
+ {
44
+ "sub", "username", "primary_email", "primary_phone", "name", "avatar", "custom_data",
45
+ "identities", "is_suspended", "has_password", "last_sign_in_at", "created_at",
46
+ "updated_at", "roles", "scopes", "organization_roles",
47
+ }
48
+ )
49
+ _KNOWN_TENANT_FIELDS = frozenset({"id", "code", "idp_users_sub", "key", "created_at", "updated_at"})
50
+ _KNOWN_ORG_ENTRY_FIELDS = frozenset({"id", "name", "roles", "tenant"})
51
+
52
+
53
+ def assert_iam_user_context(body: Any, *, strict_unknown_fields: bool = False) -> IamUserContext:
54
+ """The "permissive about unknown fields, strict about known ones" enforcement point (§C.5.1).
55
+
56
+ Unknown keys, at ANY level, are ALWAYS tolerated and preserved (the returned value IS `body`,
57
+ unmodified) — there is no allow-list construct that could reject a field the provider adds
58
+ later. `strict_unknown_fields=True` (opt-in canary, never the default) additionally checks for
59
+ unknown keys and raises if any are found; the default path never even computes that check.
60
+ """
61
+ if not isinstance(body, dict):
62
+ raise ProtocolError("Malformed IAM /iam/me response")
63
+
64
+ user = body.get("user")
65
+ if not isinstance(user, dict) or not isinstance(user.get("sub"), str) or not user["sub"]:
66
+ raise ProtocolError("Malformed IAM /iam/me response")
67
+ if "roles" in user and not isinstance(user["roles"], list):
68
+ raise ProtocolError("Malformed IAM /iam/me response")
69
+ if "scopes" in user and not isinstance(user["scopes"], list):
70
+ raise ProtocolError("Malformed IAM /iam/me response")
71
+
72
+ tenant = body.get("tenant")
73
+ if not isinstance(tenant, dict) or not isinstance(tenant.get("id"), str) or not tenant["id"]:
74
+ raise ProtocolError("Malformed IAM /iam/me response")
75
+
76
+ organizations = body.get("organizations")
77
+ if organizations is not None:
78
+ if not isinstance(organizations, dict):
79
+ raise ProtocolError("Malformed IAM /iam/me response")
80
+ for key in ("own", "as_member"):
81
+ if key in organizations and not isinstance(organizations[key], list):
82
+ raise ProtocolError("Malformed IAM /iam/me response")
83
+
84
+ permissions = body.get("permissions")
85
+ if permissions is not None and not isinstance(permissions, list):
86
+ raise ProtocolError("Malformed IAM /iam/me response")
87
+
88
+ if strict_unknown_fields:
89
+ _assert_no_unknown_fields(body, user, tenant, organizations)
90
+
91
+ return body # type: ignore[return-value]
92
+
93
+
94
+ def _assert_no_unknown_fields(
95
+ body: dict[str, Any], user: dict[str, Any], tenant: dict[str, Any], organizations: Any
96
+ ) -> None:
97
+ unknown = set(body) - _KNOWN_TOP_LEVEL
98
+ if unknown:
99
+ raise ProtocolError(f"IAM /iam/me response has unknown top-level field(s): {sorted(unknown)}")
100
+ unknown = set(user) - _KNOWN_USER_FIELDS
101
+ if unknown:
102
+ raise ProtocolError(f"IAM /iam/me response has unknown user field(s): {sorted(unknown)}")
103
+ unknown = set(tenant) - _KNOWN_TENANT_FIELDS
104
+ if unknown:
105
+ raise ProtocolError(f"IAM /iam/me response has unknown tenant field(s): {sorted(unknown)}")
106
+ if isinstance(organizations, dict):
107
+ for entries in (organizations.get("own") or [], organizations.get("as_member") or []):
108
+ for entry in entries:
109
+ if isinstance(entry, dict):
110
+ unknown = set(entry) - _KNOWN_ORG_ENTRY_FIELDS
111
+ if unknown:
112
+ raise ProtocolError(
113
+ f"IAM /iam/me response has unknown organization field(s): {sorted(unknown)}"
114
+ )
115
+
116
+
117
+ def _project_org_entry(entry: dict[str, Any], *, strip_key: bool) -> IamOrgEntry:
118
+ raw_tenant = entry.get("tenant")
119
+ projected_tenant = None
120
+ if isinstance(raw_tenant, dict):
121
+ projected_tenant = {
122
+ "id": raw_tenant.get("id"),
123
+ "code": raw_tenant.get("code"),
124
+ "idp_users_sub": raw_tenant.get("idp_users_sub"),
125
+ }
126
+ key = raw_tenant.get("key")
127
+ # strip_key=True (as_member) => the key is NEVER copied, even when the wire sent one —
128
+ # the C.5.1 / tenantKeyRedaction enforcement that must hold regardless of provider intent.
129
+ if not strip_key and isinstance(key, str) and key:
130
+ projected_tenant["key"] = key
131
+ return {
132
+ "id": entry.get("id"),
133
+ "name": entry.get("name"),
134
+ "roles": list(entry.get("roles") or []),
135
+ "tenant": projected_tenant,
136
+ }
137
+
138
+
139
+ def project_session(body: IamUserContext, role_mapper: RoleMapper) -> IamSession:
140
+ """Ported from `iam-client.ts:75-110`."""
141
+ user = body["user"] # type: ignore[index]
142
+ tenant = body["tenant"] # type: ignore[index]
143
+ organizations = body.get("organizations") or {} # type: ignore[union-attr]
144
+ own_raw = organizations.get("own") or []
145
+ as_member_raw = organizations.get("as_member") or []
146
+ idp_roles = list(user.get("roles") or [])
147
+ scopes = list(user.get("scopes") or [])
148
+ permissions = list(body.get("permissions") or []) # type: ignore[union-attr]
149
+
150
+ email_verified = user.get("email_verified")
151
+ session: IamSession = {
152
+ "user_id": user["sub"],
153
+ "idp_subject": user["sub"],
154
+ "email": user.get("primary_email") or "",
155
+ "email_verified": email_verified if isinstance(email_verified, bool) else None,
156
+ "tenant_id": tenant["id"],
157
+ "tenant_code": tenant.get("code") or "",
158
+ "idp_roles": idp_roles,
159
+ "roles": role_mapper.map_all(idp_roles),
160
+ "scopes": scopes,
161
+ "permissions": permissions,
162
+ "is_global_admin": role_mapper.is_global_admin(idp_roles),
163
+ "organizations": {
164
+ "own": [_project_org_entry(o, strip_key=False) for o in own_raw],
165
+ "as_member": [_project_org_entry(o, strip_key=True) for o in as_member_raw],
166
+ },
167
+ }
168
+ display_name = user.get("name")
169
+ if display_name:
170
+ session["display_name"] = display_name
171
+ tenant_key = tenant.get("key")
172
+ if isinstance(tenant_key, str) and tenant_key:
173
+ session["tenant_key"] = tenant_key
174
+ return session
175
+
176
+
177
+ def resolve_caller_headers(config: ResolvedConfig, override_mode: Optional[IamCallerMode]) -> tuple[dict[str, str], str]:
178
+ """Returns EXACTLY ONE marker header (or none, for m2m) + the caller kind (§C.5, R6)."""
179
+ mode = override_mode or config["iam_caller_mode"]
180
+
181
+ def appiam() -> tuple[dict[str, str], str]:
182
+ if config.get("iam_app_id") and config.get("iam_app_secret"):
183
+ return (
184
+ {"X-AppIam-Id": config["iam_app_id"], "Authorization": f"Bearer {config['iam_app_secret']}"},
185
+ "appiam",
186
+ )
187
+ raise ConfigError("IAM caller credentials are not configured")
188
+
189
+ def appidp() -> tuple[dict[str, str], str]:
190
+ if config.get("client_id") and config.get("client_secret"):
191
+ return (
192
+ {"X-AppIdp-Id": config["client_id"], "Authorization": f"Bearer {config['client_secret']}"},
193
+ "appidp",
194
+ )
195
+ raise ConfigError("IAM caller credentials are not configured")
196
+
197
+ def m2m() -> tuple[dict[str, str], str]:
198
+ if config.get("iam_m2m_token"):
199
+ return {"Authorization": f"Bearer {config['iam_m2m_token']}"}, "m2m"
200
+ raise ConfigError("IAM caller credentials are not configured")
201
+
202
+ if mode == "appiam":
203
+ return appiam()
204
+ if mode == "appidp":
205
+ return appidp()
206
+ if mode == "m2m":
207
+ return m2m()
208
+
209
+ # auto: precedence appiam -> appidp -> m2m (memybase's order, §C.5).
210
+ if config.get("iam_app_id") and config.get("iam_app_secret"):
211
+ return appiam()
212
+ if config.get("client_id") and config.get("client_secret"):
213
+ return appidp()
214
+ if config.get("iam_m2m_token"):
215
+ return m2m()
216
+ raise ConfigError("IAM caller credentials are not configured")
217
+
218
+
219
+ class IamService:
220
+ def __init__(
221
+ self,
222
+ *,
223
+ config: ResolvedConfig,
224
+ transport: Optional[httpx.BaseTransport] = None,
225
+ ) -> None:
226
+ self._config = config
227
+ self._transport = Transport(
228
+ timeout_ms=config["iam_timeout_ms"],
229
+ max_retries=config["retry"]["max_retries"],
230
+ base_delay_ms=config["retry"]["base_delay_ms"],
231
+ retryable_statuses=config["retry"]["retryable_statuses"],
232
+ transport=transport,
233
+ )
234
+ self._role_mapper = RoleMapper(
235
+ role_map=config["role_map"],
236
+ role_overrides=config["role_overrides"],
237
+ default_role=config["default_role"],
238
+ global_admin_roles=config["global_admin_roles"],
239
+ )
240
+
241
+ def me_raw(
242
+ self,
243
+ user_token: str,
244
+ *,
245
+ caller_mode: Optional[IamCallerMode] = None,
246
+ timeout_ms: Optional[int] = None,
247
+ headers: Optional[dict[str, str]] = None,
248
+ ) -> IamUserContext:
249
+ if not user_token:
250
+ raise UnauthorizedError("Missing user token")
251
+ auth_headers, _kind = resolve_caller_headers(self._config, caller_mode)
252
+ req_headers = {**auth_headers, "X-User-Token": user_token, "Accept": "application/json", **(headers or {})}
253
+ url = f"{self._config['iam_base_url']}/iam/me"
254
+ body = self._transport.request_json(
255
+ "GET", url, headers=req_headers, timeout_ms=timeout_ms if timeout_ms is not None else self._config["iam_timeout_ms"]
256
+ )
257
+ return assert_iam_user_context(body, strict_unknown_fields=self._config["strict_unknown_fields"])
258
+
259
+ def me(
260
+ self,
261
+ user_token: str,
262
+ *,
263
+ caller_mode: Optional[IamCallerMode] = None,
264
+ timeout_ms: Optional[int] = None,
265
+ headers: Optional[dict[str, str]] = None,
266
+ ) -> IamSession:
267
+ body = self.me_raw(user_token, caller_mode=caller_mode, timeout_ms=timeout_ms, headers=headers)
268
+ return project_session(body, self._role_mapper)
269
+
270
+ def tenant_by_key(
271
+ self,
272
+ tenant_key: str,
273
+ *,
274
+ caller_mode: Optional[IamCallerMode] = None,
275
+ timeout_ms: Optional[int] = None,
276
+ headers: Optional[dict[str, str]] = None,
277
+ ) -> IamTenant:
278
+ if not tenant_key:
279
+ raise UnauthorizedError("Missing tenant key")
280
+ auth_headers, _kind = resolve_caller_headers(self._config, caller_mode)
281
+ req_headers = {**auth_headers, "X-Tenant-Key": tenant_key, "Accept": "application/json", **(headers or {})}
282
+ url = f"{self._config['iam_base_url']}/iam/tenants/me"
283
+ body = self._transport.request_json(
284
+ "GET", url, headers=req_headers, timeout_ms=timeout_ms if timeout_ms is not None else self._config["iam_timeout_ms"]
285
+ )
286
+ if not isinstance(body, dict) or not isinstance(body.get("id"), str) or not body["id"]:
287
+ raise ProtocolError("Malformed IAM /iam/tenants/me response")
288
+ return body # type: ignore[return-value]
289
+
290
+ def health(self) -> bool:
291
+ """Never throws."""
292
+ try:
293
+ url = f"{self._config['iam_base_url']}/iam/health"
294
+ resp = self._transport.send("GET", url)
295
+ return 200 <= resp.status_code < 300
296
+ except Exception: # noqa: BLE001 - a health probe must never raise
297
+ return False
298
+
299
+ def close(self) -> None:
300
+ self._transport.close()
@@ -0,0 +1,167 @@
1
+ # GENERATED by scripts/gen_sync.py from memyauth/_idtoken.py — DO NOT EDIT.
2
+ # Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
3
+ """
4
+ @fileoverview Capability C0 — ID-token trust strategies: local JWKS verification, or an explicit
5
+ no-verification mode for TLS-token-endpoint trust.
6
+ @module memyauth._idtoken
7
+ @description [async -> _sync/_idtoken.py twin via scripts/gen_sync.py]. BOTH trust strategies are
8
+ first-class and BOTH are in live production use (§C.10.1): `local-jwks` (memyui, roboson,
9
+ memyvet) and `tls-token-endpoint` (memybase). `mode` has no default at this layer — the caller
10
+ MUST pick one, mirroring `_config.py`'s `id_token_validation` (§C.16). This is the module the
11
+ mandatory RS256 negative test (R1) exercises: a pin that cannot verify ANY token this provider
12
+ issues must fail LOUDLY, and `UnsupportedAlgorithmError` must be raised on the `alg` header
13
+ BEFORE any key lookup — a verifier that skipped the algorithm check and just tried every key
14
+ would accept a genuinely-signed RS256 token, which is exactly the twice-shipped defect (D4)
15
+ this class exists to make impossible. Exp/nbf are checked against the INJECTED `Clock`, not
16
+ PyJWT's internal wall-clock read, so the conformance harness's `fixedClock` can pin determinism.
17
+ @dependencies pyjwt[crypto]
18
+ @relatedFiles _discovery.py (source of `jwks_uri` — always the internal/fetch origin, never
19
+ rewritten, since JWKS is a server-side endpoint per §C.1 step 6), _config.py
20
+ (`allowed_algorithms`/`id_token_validation`), _errors.py, tests/test_idtoken.py
21
+ @created 2026-09-06
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from typing import Any, Optional
26
+
27
+ import httpx
28
+ import jwt
29
+
30
+ # ABSOLUTE imports for the shared/pure modules; RELATIVE for ._discovery/._transport, which DO get
31
+ # _sync/ twins and must bind to THEIR sibling twins (see _transport.py's header for why).
32
+ from memyauth._errors import ConfigError, IdTokenError, UnsupportedAlgorithmError
33
+ from memyauth._types import Clock, IdpTokenClaims, IdTokenValidationMode, SystemClock
34
+
35
+ from ._discovery import DiscoveryService
36
+ from ._transport import Transport, read_json_or_none
37
+
38
+ __all__ = ["IdTokenVerifier"]
39
+
40
+ _DEFAULT_LOCAL_JWKS_ALGORITHMS = ("ES384",)
41
+ _DEFAULT_CLOCK_TOLERANCE_SECONDS = 60
42
+
43
+
44
+ class IdTokenVerifier:
45
+ def __init__(
46
+ self,
47
+ *,
48
+ mode: IdTokenValidationMode,
49
+ allowed_algorithms: Optional[list[str]] = None,
50
+ discovery: DiscoveryService,
51
+ clock_tolerance_seconds: int = _DEFAULT_CLOCK_TOLERANCE_SECONDS,
52
+ transport: Optional[httpx.BaseTransport] = None,
53
+ clock: Optional[Clock] = None,
54
+ ) -> None:
55
+ if mode not in ("local-jwks", "tls-token-endpoint"):
56
+ raise ConfigError("idTokenValidation must be local-jwks or tls-token-endpoint")
57
+
58
+ resolved_algorithms = allowed_algorithms
59
+ if resolved_algorithms is None:
60
+ resolved_algorithms = list(_DEFAULT_LOCAL_JWKS_ALGORITHMS) if mode == "local-jwks" else []
61
+
62
+ if mode == "tls-token-endpoint" and resolved_algorithms:
63
+ raise ConfigError("allowedAlgorithms must be empty when idTokenValidation is tls-token-endpoint")
64
+ if mode == "local-jwks" and not resolved_algorithms:
65
+ raise ConfigError("allowedAlgorithms must not be empty when idTokenValidation is local-jwks")
66
+
67
+ self.mode: IdTokenValidationMode = mode
68
+ self._allowed_algorithms = set(resolved_algorithms)
69
+ self._discovery = discovery
70
+ self._clock_tolerance_seconds = clock_tolerance_seconds
71
+ self._clock: Clock = clock or SystemClock()
72
+ self._transport = Transport(transport=transport)
73
+ self._jwks_cache: dict[str, dict[str, Any]] = {}
74
+
75
+ def _refresh_jwks(self) -> None:
76
+ doc = self._discovery.discover()
77
+ resp = self._transport.send("GET", doc["jwks_uri"])
78
+ if not (200 <= resp.status_code < 300):
79
+ raise IdTokenError(f"failed to fetch JWKS: {resp.status_code}")
80
+ body = read_json_or_none(resp)
81
+ keys = (body or {}).get("keys", []) if isinstance(body, dict) else []
82
+ self._jwks_cache = {k["kid"]: k for k in keys if isinstance(k, dict) and "kid" in k}
83
+
84
+ def _get_key_for_kid(self, kid: str) -> dict[str, Any]:
85
+ if kid not in self._jwks_cache:
86
+ self._refresh_jwks() # the single forced refresh on an unknown kid
87
+ if kid not in self._jwks_cache:
88
+ raise IdTokenError(f"no matching JWKS key for kid={kid!r}")
89
+ return self._jwks_cache[kid]
90
+
91
+ def verify(
92
+ self,
93
+ id_token: str,
94
+ *,
95
+ issuer: str,
96
+ audience: str,
97
+ nonce: Optional[str] = None,
98
+ max_age_seconds: Optional[int] = None,
99
+ ) -> IdpTokenClaims:
100
+ if self.mode == "tls-token-endpoint":
101
+ raise ConfigError(
102
+ "tls-token-endpoint mode does not verify ID tokens; identity is derived from the "
103
+ "token-endpoint TLS channel per OIDC Core 3.1.3.7 item 6 — use decode_unverified() "
104
+ "plus /oidc/me or /iam/me"
105
+ )
106
+
107
+ try:
108
+ header = jwt.get_unverified_header(id_token)
109
+ except jwt.PyJWTError as exc:
110
+ raise IdTokenError(f"malformed ID token: {exc}") from exc
111
+
112
+ alg = header.get("alg")
113
+ # THE R1 check: reject on `alg` BEFORE any key lookup — a verifier that tried every key
114
+ # regardless of alg would accept a genuinely-signed RS256 token under an ES384-only pin.
115
+ if alg not in self._allowed_algorithms:
116
+ raise UnsupportedAlgorithmError(
117
+ f"ID token alg {alg!r} is not in the allowed algorithms {sorted(self._allowed_algorithms)!r}"
118
+ )
119
+
120
+ kid = header.get("kid")
121
+ if not kid:
122
+ raise IdTokenError("ID token is missing a kid header")
123
+ jwk = self._get_key_for_kid(kid)
124
+ key = jwt.PyJWK(jwk).key
125
+
126
+ try:
127
+ claims = jwt.decode(
128
+ id_token,
129
+ key,
130
+ algorithms=[alg],
131
+ audience=audience,
132
+ issuer=issuer,
133
+ options={"verify_exp": False, "verify_nbf": False, "require": ["exp", "iat"]},
134
+ )
135
+ except jwt.PyJWTError as exc:
136
+ raise IdTokenError(f"ID token verification failed: {exc}") from exc
137
+
138
+ # exp/nbf checked against the INJECTED clock, not PyJWT's wall-clock read (determinism).
139
+ now_seconds = self._clock.now() / 1000
140
+ tolerance = self._clock_tolerance_seconds
141
+ exp = claims.get("exp")
142
+ if not isinstance(exp, (int, float)) or now_seconds > exp + tolerance:
143
+ raise IdTokenError("ID token has expired")
144
+ nbf = claims.get("nbf")
145
+ if isinstance(nbf, (int, float)) and now_seconds < nbf - tolerance:
146
+ raise IdTokenError("ID token is not yet valid")
147
+
148
+ if nonce is not None and claims.get("nonce") != nonce:
149
+ raise IdTokenError("ID token nonce mismatch")
150
+
151
+ if max_age_seconds is not None:
152
+ auth_time = claims.get("auth_time")
153
+ if not isinstance(auth_time, (int, float)) or (now_seconds - auth_time) > max_age_seconds:
154
+ raise IdTokenError("ID token auth_time exceeds max_age_seconds")
155
+
156
+ return claims # type: ignore[return-value]
157
+
158
+ def decode_unverified(self, id_token: str) -> IdpTokenClaims:
159
+ """Decode without verifying. Available in BOTH modes; the ONLY path in
160
+ `tls-token-endpoint` mode."""
161
+ try:
162
+ return jwt.decode(id_token, options={"verify_signature": False}) # type: ignore[return-value]
163
+ except jwt.PyJWTError as exc:
164
+ raise IdTokenError(f"malformed ID token: {exc}") from exc
165
+
166
+ def close(self) -> None:
167
+ self._transport.close()