memydev-auth-sdk 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (64) hide show
  1. memydev_auth_sdk-0.2.0/PKG-INFO +145 -0
  2. memydev_auth_sdk-0.2.0/README.md +128 -0
  3. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/pyproject.toml +1 -1
  4. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/scripts/gen_sync.py +16 -2
  5. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/__init__.py +76 -3
  6. memydev_auth_sdk-0.2.0/src/memyauth/_caller.py +167 -0
  7. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_client.py +20 -2
  8. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_config.py +22 -0
  9. memydev_auth_sdk-0.2.0/src/memyauth/_contract.py +65 -0
  10. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_errors.py +85 -1
  11. memydev_auth_sdk-0.2.0/src/memyauth/_headers.py +26 -0
  12. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_iam.py +115 -48
  13. memydev_auth_sdk-0.2.0/src/memyauth/_iam_shapes.py +405 -0
  14. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_oidc.py +15 -1
  15. memydev_auth_sdk-0.2.0/src/memyauth/_sync/_caller.py +169 -0
  16. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_client.py +20 -2
  17. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_iam.py +115 -48
  18. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_oidc.py +15 -1
  19. memydev_auth_sdk-0.2.0/src/memyauth/_sync/_tenant_admin.py +379 -0
  20. memydev_auth_sdk-0.2.0/src/memyauth/_sync/_tenant_keys.py +214 -0
  21. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_transport.py +25 -4
  22. memydev_auth_sdk-0.2.0/src/memyauth/_sync/_webhooks.py +105 -0
  23. memydev_auth_sdk-0.2.0/src/memyauth/_tenant_admin.py +377 -0
  24. memydev_auth_sdk-0.2.0/src/memyauth/_tenant_keys.py +212 -0
  25. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_transport.py +25 -4
  26. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_types.py +213 -3
  27. memydev_auth_sdk-0.2.0/src/memyauth/_webhook_receiver.py +164 -0
  28. memydev_auth_sdk-0.2.0/src/memyauth/_webhooks.py +103 -0
  29. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/sync/__init__.py +73 -2
  30. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_client.py +14 -0
  31. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_config.py +25 -0
  32. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_contract_parity.py +77 -0
  33. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_errors.py +42 -0
  34. memydev_auth_sdk-0.2.0/tests/test_iam_lookups.py +162 -0
  35. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_oidc.py +18 -0
  36. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_sync_parity.py +12 -1
  37. memydev_auth_sdk-0.2.0/tests/test_tenant_admin.py +352 -0
  38. memydev_auth_sdk-0.2.0/tests/test_tenant_keys.py +365 -0
  39. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_transport.py +43 -0
  40. memydev_auth_sdk-0.2.0/tests/test_webhooks.py +251 -0
  41. memydev_auth_sdk-0.1.0/PKG-INFO +0 -86
  42. memydev_auth_sdk-0.1.0/README.md +0 -69
  43. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/.gitignore +0 -0
  44. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/requirements-release.in +0 -0
  45. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/requirements-release.lock +0 -0
  46. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_discovery.py +0 -0
  47. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_idtoken.py +0 -0
  48. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_pkce.py +0 -0
  49. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_roles.py +0 -0
  50. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_session.py +0 -0
  51. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_state.py +0 -0
  52. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/__init__.py +0 -0
  53. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_discovery.py +0 -0
  54. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_idtoken.py +0 -0
  55. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/py.typed +0 -0
  56. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/conftest.py +0 -0
  57. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_discovery.py +0 -0
  58. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_iam.py +0 -0
  59. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_idtoken.py +0 -0
  60. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_pkce.py +0 -0
  61. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_roles.py +0 -0
  62. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_session.py +0 -0
  63. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_state.py +0 -0
  64. {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_sync_functional.py +0 -0
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: memydev-auth-sdk
3
+ Version: 0.2.0
4
+ Summary: Official MemyAuth (OIDC + IAM) client SDK for Python
5
+ License: Proprietary and unlicensed pending approved service terms.
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: httpx<1,>=0.27
8
+ Requires-Dist: pyjwt[crypto]<3,>=2.9
9
+ Provides-Extra: dev
10
+ Requires-Dist: editables==0.5; extra == 'dev'
11
+ Requires-Dist: hatchling==1.27.0; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio==0.24.0; extra == 'dev'
13
+ Requires-Dist: pytest==8.3.5; extra == 'dev'
14
+ Requires-Dist: respx==0.22.0; extra == 'dev'
15
+ Requires-Dist: unasync==0.6.0; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # memydev-auth-sdk
19
+
20
+ Official Python client for **MemyAuth** — the estate's OIDC + IAM provider. Import name: `memyauth`.
21
+
22
+ ```python
23
+ import asyncio
24
+ from memyauth import MemyAuth
25
+
26
+ async def main() -> None:
27
+ async with MemyAuth(
28
+ issuer_url="https://auth.memy.dev/oidc",
29
+ client_id="my-app",
30
+ client_secret="...",
31
+ public_base_url="https://my-app.example.com",
32
+ id_token_validation="local-jwks", # or "tls-token-endpoint" — see below, no default
33
+ ) as auth:
34
+ req = await auth.oidc.build_authorization_url()
35
+ # redirect the browser to req.url, persist req via auth.state.sign(...)
36
+ ...
37
+
38
+ asyncio.run(main())
39
+ ```
40
+
41
+ A synchronous facade is available for non-async callers:
42
+
43
+ ```python
44
+ from memyauth.sync import MemyAuth
45
+
46
+ with MemyAuth(issuer_url=..., client_id=..., client_secret=..., id_token_validation="local-jwks") as auth:
47
+ tokens = auth.oidc.exchange_code(code=..., code_verifier=...)
48
+ ```
49
+
50
+ ## IAM: identity, tenants, webhooks, tenant keys, tenant administration
51
+
52
+ Every relying-party operation of the IAM API (2.8.0) is covered. Projections are snake_case dicts with
53
+ the documented fields only (unknown wire fields are dropped, required ones are checked).
54
+
55
+ ```python
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
+ iam_app_id="my-app", # appiam caller credential (X-AppIam-Id + secret)
63
+ iam_app_secret="...",
64
+ iam_scoped_token=get_iam_token, # IAM-resource token: a string or a (coroutine) function
65
+ ) as auth:
66
+ session = await auth.iam.me(user_token) # GET /iam/me
67
+ tenant = await auth.iam.lookup_tenant(code="acme") # GET /iam/tenants
68
+ ready = await auth.iam.ready() # {"ready": True, "status": "ok", ...}
69
+
70
+ keys = await auth.tenant_keys.list_keys(user_token) # the signed-in owner's own tenant
71
+ created = await auth.tenant_keys.create_key(user_token, label="primary", rotate={"grace_seconds": 3600})
72
+ await auth.tenant_keys.update_key(user_token, key_id, expires_at=None) # None clears; omit to keep
73
+
74
+ page = await auth.tenant_admin.list_tenants(code_prefix="ac", limit=50) # in-cluster only
75
+ await auth.tenant_admin.rotate_keys(tenant_id, 0, request_id="ops-2026-10-03-001") # break-glass, no value returned
76
+ audit = await auth.tenant_admin.list_audit(tenant_id=tenant_id, action="rotate") # who changed what, newest first
77
+
78
+ config = await auth.webhooks.register(webhook_uri="https://my-app.example.com/iam-webhook")
79
+ secret = config.get("signing_secret") # present once — store it now
80
+ ```
81
+
82
+ - **Self tenant keys** need the `iam:self` grant. An appiam app holds it in IAM (`create-app --scopes
83
+ iam:self`); an appidp/m2m caller presents an IAM-resource token carrying `iam:self` (`iam_scoped_token`,
84
+ or `scoped_token=` per call) instead of its client secret. In `auto` mode `X-AppIdp-Id` is sent only when
85
+ the token's (unverified) `sub` is your `client_id`; otherwise the token goes alone (m2m).
86
+ - Every `tenant_id` / `key_id` must be a UUID, and every token or secret printable ASCII without surrounding
87
+ whitespace — both checked before any request; errors never echo the value.
88
+ - **Tenant administration** presents the IAM-resource token alone, with `iam:tenants:*` / `iam:keys:*`.
89
+ - **The SDK never mints that token.** Mint it with the client-credentials grant, `resource=IAM_API_RESOURCE`
90
+ and the scopes your Logto role grants; pass a function to refresh it — it is called on every request
91
+ that needs it.
92
+ - Errors: `TenantKeyConflictError` (`.reason` `last_key` / `max_live`), `TenantAdminConflictError`
93
+ (`code_taken`), `ScopeForbiddenError` (`.required_scopes`), `GoneError` for a deprovisioned tenant looked
94
+ up by id. Live keys are returned with their value only on the owner's own routes; the SDK never logs a
95
+ key or puts one in an error message.
96
+
97
+ Receiving IAM webhooks:
98
+
99
+ ```python
100
+ from memyauth import is_tenant_key_event, parse_tenant_event, parse_webhook_envelope, verify_webhook_signature
101
+
102
+ if not verify_webhook_signature(raw_body, headers.get("X-IAM-Webhook-Signature"), signing_secret):
103
+ return 401
104
+ event = parse_webhook_envelope(raw_body) # dedupe on event["id"]
105
+ if is_tenant_key_event(event): # Tenant.KeyCreated / KeyRotated / KeyRevoked / Deprovisioned
106
+ change = parse_tenant_event(event) # actor_kind, key_id, key_prefix, retired_keys … — never a key value
107
+ ```
108
+
109
+ ## Choosing `id_token_validation`
110
+
111
+ MemyAuth signs ID tokens with **ES384 only**. Two ID-token trust strategies are both in production
112
+ use across the estate and neither is a silent default — you must choose:
113
+
114
+ - `local-jwks` (**recommended for new consumers**): verifies the ID token's signature locally
115
+ against the provider's JWKS, with an explicit algorithm allow-list (`["ES384"]` by default).
116
+ Stateless, no extra round trip, defends against ID-token substitution.
117
+ - `tls-token-endpoint`: performs **no** local signature verification; identity is derived from the
118
+ direct TLS channel to the token endpoint (OIDC Core §3.1.3.7 item 6) plus `/oidc/me` or
119
+ `/iam/me`. Choose this only when you never process the ID token directly.
120
+
121
+ An `allowed_algorithms` pin that cannot verify any token this provider issues (e.g. `["RS256"]`)
122
+ fails loudly at construction with `UnsupportedAlgorithmPinError` — this SDK exists in large part to
123
+ make that historically-real defect (shipped twice against this provider) impossible to repeat
124
+ silently.
125
+
126
+ ## Async is the source of truth
127
+
128
+ `memyauth/_*.py` are hand-written and authoritative. `memyauth/_sync/*.py` and the `memyauth.sync`
129
+ facade are **mechanically generated** from them by `scripts/gen_sync.py` (via `unasync`) — never
130
+ hand-edit `_sync/`. Regenerate after any async change:
131
+
132
+ ```
133
+ python scripts/gen_sync.py # write the twins
134
+ python scripts/gen_sync.py --check # verify no drift; exits non-zero if the twins are stale
135
+ ```
136
+
137
+ ## Development
138
+
139
+ ```
140
+ python3 -m venv .venv
141
+ .venv/bin/python -m pip install --upgrade pip
142
+ .venv/bin/python -m pip install hatchling==1.27.0 editables==0.5
143
+ .venv/bin/python -m pip install --no-build-isolation -e ".[dev]"
144
+ .venv/bin/python -m pytest -q
145
+ ```
@@ -0,0 +1,128 @@
1
+ # memydev-auth-sdk
2
+
3
+ Official Python client for **MemyAuth** — the estate's OIDC + IAM provider. Import name: `memyauth`.
4
+
5
+ ```python
6
+ import asyncio
7
+ from memyauth import MemyAuth
8
+
9
+ async def main() -> None:
10
+ async with MemyAuth(
11
+ issuer_url="https://auth.memy.dev/oidc",
12
+ client_id="my-app",
13
+ client_secret="...",
14
+ public_base_url="https://my-app.example.com",
15
+ id_token_validation="local-jwks", # or "tls-token-endpoint" — see below, no default
16
+ ) as auth:
17
+ req = await auth.oidc.build_authorization_url()
18
+ # redirect the browser to req.url, persist req via auth.state.sign(...)
19
+ ...
20
+
21
+ asyncio.run(main())
22
+ ```
23
+
24
+ A synchronous facade is available for non-async callers:
25
+
26
+ ```python
27
+ from memyauth.sync import MemyAuth
28
+
29
+ with MemyAuth(issuer_url=..., client_id=..., client_secret=..., id_token_validation="local-jwks") as auth:
30
+ tokens = auth.oidc.exchange_code(code=..., code_verifier=...)
31
+ ```
32
+
33
+ ## IAM: identity, tenants, webhooks, tenant keys, tenant administration
34
+
35
+ Every relying-party operation of the IAM API (2.8.0) is covered. Projections are snake_case dicts with
36
+ the documented fields only (unknown wire fields are dropped, required ones are checked).
37
+
38
+ ```python
39
+ async with MemyAuth(
40
+ issuer_url="https://auth.memy.dev/oidc",
41
+ client_id="my-app",
42
+ client_secret="...",
43
+ public_base_url="https://my-app.example.com",
44
+ id_token_validation="local-jwks",
45
+ iam_app_id="my-app", # appiam caller credential (X-AppIam-Id + secret)
46
+ iam_app_secret="...",
47
+ iam_scoped_token=get_iam_token, # IAM-resource token: a string or a (coroutine) function
48
+ ) as auth:
49
+ session = await auth.iam.me(user_token) # GET /iam/me
50
+ tenant = await auth.iam.lookup_tenant(code="acme") # GET /iam/tenants
51
+ ready = await auth.iam.ready() # {"ready": True, "status": "ok", ...}
52
+
53
+ keys = await auth.tenant_keys.list_keys(user_token) # the signed-in owner's own tenant
54
+ created = await auth.tenant_keys.create_key(user_token, label="primary", rotate={"grace_seconds": 3600})
55
+ await auth.tenant_keys.update_key(user_token, key_id, expires_at=None) # None clears; omit to keep
56
+
57
+ page = await auth.tenant_admin.list_tenants(code_prefix="ac", limit=50) # in-cluster only
58
+ await auth.tenant_admin.rotate_keys(tenant_id, 0, request_id="ops-2026-10-03-001") # break-glass, no value returned
59
+ audit = await auth.tenant_admin.list_audit(tenant_id=tenant_id, action="rotate") # who changed what, newest first
60
+
61
+ config = await auth.webhooks.register(webhook_uri="https://my-app.example.com/iam-webhook")
62
+ secret = config.get("signing_secret") # present once — store it now
63
+ ```
64
+
65
+ - **Self tenant keys** need the `iam:self` grant. An appiam app holds it in IAM (`create-app --scopes
66
+ iam:self`); an appidp/m2m caller presents an IAM-resource token carrying `iam:self` (`iam_scoped_token`,
67
+ or `scoped_token=` per call) instead of its client secret. In `auto` mode `X-AppIdp-Id` is sent only when
68
+ the token's (unverified) `sub` is your `client_id`; otherwise the token goes alone (m2m).
69
+ - Every `tenant_id` / `key_id` must be a UUID, and every token or secret printable ASCII without surrounding
70
+ whitespace — both checked before any request; errors never echo the value.
71
+ - **Tenant administration** presents the IAM-resource token alone, with `iam:tenants:*` / `iam:keys:*`.
72
+ - **The SDK never mints that token.** Mint it with the client-credentials grant, `resource=IAM_API_RESOURCE`
73
+ and the scopes your Logto role grants; pass a function to refresh it — it is called on every request
74
+ that needs it.
75
+ - Errors: `TenantKeyConflictError` (`.reason` `last_key` / `max_live`), `TenantAdminConflictError`
76
+ (`code_taken`), `ScopeForbiddenError` (`.required_scopes`), `GoneError` for a deprovisioned tenant looked
77
+ up by id. Live keys are returned with their value only on the owner's own routes; the SDK never logs a
78
+ key or puts one in an error message.
79
+
80
+ Receiving IAM webhooks:
81
+
82
+ ```python
83
+ from memyauth import is_tenant_key_event, parse_tenant_event, parse_webhook_envelope, verify_webhook_signature
84
+
85
+ if not verify_webhook_signature(raw_body, headers.get("X-IAM-Webhook-Signature"), signing_secret):
86
+ return 401
87
+ event = parse_webhook_envelope(raw_body) # dedupe on event["id"]
88
+ if is_tenant_key_event(event): # Tenant.KeyCreated / KeyRotated / KeyRevoked / Deprovisioned
89
+ change = parse_tenant_event(event) # actor_kind, key_id, key_prefix, retired_keys … — never a key value
90
+ ```
91
+
92
+ ## Choosing `id_token_validation`
93
+
94
+ MemyAuth signs ID tokens with **ES384 only**. Two ID-token trust strategies are both in production
95
+ use across the estate and neither is a silent default — you must choose:
96
+
97
+ - `local-jwks` (**recommended for new consumers**): verifies the ID token's signature locally
98
+ against the provider's JWKS, with an explicit algorithm allow-list (`["ES384"]` by default).
99
+ Stateless, no extra round trip, defends against ID-token substitution.
100
+ - `tls-token-endpoint`: performs **no** local signature verification; identity is derived from the
101
+ direct TLS channel to the token endpoint (OIDC Core §3.1.3.7 item 6) plus `/oidc/me` or
102
+ `/iam/me`. Choose this only when you never process the ID token directly.
103
+
104
+ An `allowed_algorithms` pin that cannot verify any token this provider issues (e.g. `["RS256"]`)
105
+ fails loudly at construction with `UnsupportedAlgorithmPinError` — this SDK exists in large part to
106
+ make that historically-real defect (shipped twice against this provider) impossible to repeat
107
+ silently.
108
+
109
+ ## Async is the source of truth
110
+
111
+ `memyauth/_*.py` are hand-written and authoritative. `memyauth/_sync/*.py` and the `memyauth.sync`
112
+ facade are **mechanically generated** from them by `scripts/gen_sync.py` (via `unasync`) — never
113
+ hand-edit `_sync/`. Regenerate after any async change:
114
+
115
+ ```
116
+ python scripts/gen_sync.py # write the twins
117
+ python scripts/gen_sync.py --check # verify no drift; exits non-zero if the twins are stale
118
+ ```
119
+
120
+ ## Development
121
+
122
+ ```
123
+ python3 -m venv .venv
124
+ .venv/bin/python -m pip install --upgrade pip
125
+ .venv/bin/python -m pip install hatchling==1.27.0 editables==0.5
126
+ .venv/bin/python -m pip install --no-build-isolation -e ".[dev]"
127
+ .venv/bin/python -m pytest -q
128
+ ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memydev-auth-sdk"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Official MemyAuth (OIDC + IAM) client SDK for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -3,7 +3,8 @@
3
3
  @fileoverview Generate the SYNCHRONOUS memyauth SDK twins from the async sources via unasync.
4
4
  @module memyauth.scripts.gen_sync
5
5
  @description Single-source generation of memyauth/_sync/*.py from the hand-written async modules
6
- (`_client.py`, `_discovery.py`, `_oidc.py`, `_iam.py`, `_idtoken.py`, `_transport.py`) — the
6
+ (`_client.py`, `_discovery.py`, `_oidc.py`, `_iam.py`, `_idtoken.py`, `_transport.py`; since 0.2.0
7
+ also `_caller.py`, `_webhooks.py`, `_tenant_keys.py`, `_tenant_admin.py`) — the
7
8
  Python-side mirror of the *design pattern* used by `sdk/contract/scripts/sync-contract.mjs`
8
9
  (single-source-of-truth + a `--check` drift gate), NOT the same tool and NOT interchangeable
9
10
  with it. `sync-contract.mjs` VENDORS TYPES from provider TypeScript source files
@@ -52,7 +53,20 @@ SYNC_DIR = PKG / "_sync"
52
53
  # _config, _types, _pkce) have zero async/await and are imported directly by BOTH surfaces —
53
54
  # generating a twin for them would just be a byte-identical copy, so gen_sync intentionally
54
55
  # doesn't touch them (mirrors memybase's own ASYNC_MODULES scoping).
55
- ASYNC_MODULES = ["_client", "_discovery", "_oidc", "_iam", "_idtoken", "_transport"]
56
+ ASYNC_MODULES = [
57
+ "_client",
58
+ "_discovery",
59
+ "_oidc",
60
+ "_iam",
61
+ "_idtoken",
62
+ "_transport",
63
+ # 0.2.0 — the shared caller-header resolver (its one async helper resolves an awaitable token
64
+ # source) and the three new IAM services.
65
+ "_caller",
66
+ "_webhooks",
67
+ "_tenant_keys",
68
+ "_tenant_admin",
69
+ ]
56
70
 
57
71
  _RULE = unasync.Rule(
58
72
  fromdir="/memyauth/",
@@ -3,11 +3,15 @@
3
3
  @module memyauth
4
4
  @description Public entrypoint. Async: `memyauth.MemyAuth`. Sync: `memyauth.sync.MemyAuth`. The
5
5
  async modules (`_client.py`, `_discovery.py`, `_oidc.py`, `_iam.py`, `_idtoken.py`,
6
- `_transport.py`) are the ONLY hand-written client surface; `_sync/*.py` is mechanically derived
6
+ `_transport.py`, and since 0.2.0 `_caller.py`, `_webhooks.py`, `_tenant_keys.py`,
7
+ `_tenant_admin.py`) are the ONLY hand-written client surface; `_sync/*.py` is mechanically derived
7
8
  by `scripts/gen_sync.py` — never hand-edit it. `_roles.py`, `_session.py`, `_state.py`,
8
- `_config.py`, `_errors.py`, `_types.py`, `_pkce.py` are pure/synchronous and shared verbatim by
9
- both surfaces (no twin exists or is needed).
9
+ `_config.py`, `_errors.py`, `_types.py`, `_pkce.py`, `_contract.py`, `_iam_shapes.py`,
10
+ `_webhook_receiver.py` are pure/synchronous and shared verbatim by both surfaces (no twin exists or
11
+ is needed).
10
12
  @created 2026-09-06
13
+ @updated 2026-10-03 SDK 0.2.0 exports (webhooks, tenant keys, tenant administration, webhook receiver
14
+ helpers, IAM resource/scope constants, conflict/scope errors, the new shapes).
11
15
  """
12
16
  from ._client import MemyAuth, SDK_VERSION
13
17
  from ._config import ResolvedConfig, RetryConfig, resolve_config
@@ -15,6 +19,7 @@ from ._discovery import DiscoveryService
15
19
  from ._errors import (
16
20
  BadRequestError,
17
21
  ConfigError,
22
+ ConflictError,
18
23
  DiscoveryError,
19
24
  DiscoveryEscapeError,
20
25
  ForbiddenError,
@@ -26,10 +31,13 @@ from ._errors import (
26
31
  NotFoundError,
27
32
  ProtocolError,
28
33
  RefreshRejectedError,
34
+ ScopeForbiddenError,
29
35
  ServiceUnavailableError,
30
36
  SessionTokenError,
31
37
  StateCookieError,
32
38
  StateMismatchError,
39
+ TenantAdminConflictError,
40
+ TenantKeyConflictError,
33
41
  TimeoutError,
34
42
  TokenExchangeError,
35
43
  UnauthorizedError,
@@ -38,6 +46,7 @@ from ._errors import (
38
46
  UserInfoError,
39
47
  decode_error,
40
48
  )
49
+ from ._contract import ADMIN_AUDIT_ACTIONS, IAM_API_RESOURCE, IAM_SCOPES, TENANT_EVENT_TYPES
41
50
  from ._iam import IamService
42
51
  from ._idtoken import IdTokenVerifier
43
52
  from ._oidc import OidcService
@@ -45,11 +54,30 @@ from ._pkce import SystemRandomSource, generate_code_challenge, generate_code_ve
45
54
  from ._roles import CONSUMER_ROLES, DEFAULT_CONSUMER_ROLE, DEFAULT_ROLE_MAP, GLOBAL_ADMIN_IDP_ROLES, RoleMapper
46
55
  from ._session import SessionTokenService
47
56
  from ._state import StateCookieService
57
+ from ._tenant_admin import TenantAdminService
58
+ from ._tenant_keys import TenantKeyService
59
+ from ._webhook_receiver import (
60
+ WEBHOOK_HEADERS,
61
+ is_tenant_key_event,
62
+ parse_tenant_event,
63
+ parse_webhook_envelope,
64
+ verify_webhook_signature,
65
+ )
66
+ from ._webhooks import AppWebhookService
48
67
  from ._types import (
68
+ AdminAuditEntry,
69
+ AdminAuditList,
70
+ AppWebhookConfig,
71
+ AppWebhookRegistration,
72
+ AppWebhookSecret,
73
+ AppWebhookUnregistered,
49
74
  AuthorizationRequest,
50
75
  Clock,
51
76
  DiscoveryHealth,
77
+ IamOrganizationBinding,
52
78
  IamOrgEntry,
79
+ IamReadiness,
80
+ IamScopedTokenSource,
53
81
  IamSession,
54
82
  IamTenant,
55
83
  IamUserContext,
@@ -64,10 +92,20 @@ from ._types import (
64
92
  StateCookieInput,
65
93
  StateCookiePayload,
66
94
  SystemClock,
95
+ TenantAdminList,
96
+ TenantAdminSummary,
97
+ TenantKey,
98
+ TenantKeyCreated,
99
+ TenantKeyList,
100
+ TenantKeyRotate,
101
+ TenantEvent,
102
+ TenantEventKeyRef,
103
+ TenantEventRetiredKey,
67
104
  UserContext,
68
105
  UserContextOrgEntry,
69
106
  UserContextTenant,
70
107
  UserInfo,
108
+ WebhookEnvelope,
71
109
  )
72
110
 
73
111
  __version__ = SDK_VERSION
@@ -82,6 +120,18 @@ __all__ = [
82
120
  "DiscoveryService",
83
121
  "OidcService",
84
122
  "IamService",
123
+ "AppWebhookService",
124
+ "TenantKeyService",
125
+ "TenantAdminService",
126
+ "verify_webhook_signature",
127
+ "parse_webhook_envelope",
128
+ "WEBHOOK_HEADERS",
129
+ "is_tenant_key_event",
130
+ "parse_tenant_event",
131
+ "IAM_API_RESOURCE",
132
+ "IAM_SCOPES",
133
+ "ADMIN_AUDIT_ACTIONS",
134
+ "TENANT_EVENT_TYPES",
85
135
  "IdTokenVerifier",
86
136
  "RoleMapper",
87
137
  "DEFAULT_ROLE_MAP",
@@ -106,6 +156,10 @@ __all__ = [
106
156
  "NotFoundError",
107
157
  "GoneError",
108
158
  "ServiceUnavailableError",
159
+ "ConflictError",
160
+ "TenantKeyConflictError",
161
+ "TenantAdminConflictError",
162
+ "ScopeForbiddenError",
109
163
  "DiscoveryError",
110
164
  "IssuerMismatchError",
111
165
  "DiscoveryEscapeError",
@@ -134,6 +188,25 @@ __all__ = [
134
188
  "IamSession",
135
189
  "IamOrgEntry",
136
190
  "IamTenant",
191
+ "IamScopedTokenSource",
192
+ "IamReadiness",
193
+ "IamOrganizationBinding",
194
+ "AppWebhookConfig",
195
+ "AppWebhookRegistration",
196
+ "AppWebhookUnregistered",
197
+ "AppWebhookSecret",
198
+ "TenantKey",
199
+ "TenantKeyList",
200
+ "TenantKeyCreated",
201
+ "TenantKeyRotate",
202
+ "TenantAdminSummary",
203
+ "TenantAdminList",
204
+ "WebhookEnvelope",
205
+ "AdminAuditEntry",
206
+ "AdminAuditList",
207
+ "TenantEvent",
208
+ "TenantEventKeyRef",
209
+ "TenantEventRetiredKey",
137
210
  "SessionAccessInput",
138
211
  "SessionAccessClaims",
139
212
  "SessionRefreshInput",
@@ -0,0 +1,167 @@
1
+ """
2
+ @fileoverview Caller-credential header resolution shared by every IAM-facing service.
3
+ @module memyauth._caller
4
+ @description [async -> _sync/_caller.py twin via scripts/gen_sync.py — only `resolve_scoped_token`
5
+ awaits anything; the rest is pure and identical in both]. The Python twin of the JS SDK's
6
+ `caller.ts`, rule for rule. The single-marker rule (§C.5, R6) lives here once for all four IAM
7
+ services: every header set built here carries AT MOST ONE marker header (`X-AppIam-Id` or
8
+ `X-AppIdp-Id`).
9
+
10
+ 1. `resolve_caller_headers()` — `/iam/me`, `/iam/tenants*`, `/iam/users`, `/iam/organizations/{id}`,
11
+ the app-webhook routes. Moved verbatim from `_iam.py` (0.1.0; `_iam.resolve_caller_headers` still
12
+ resolves to it): appiam (app secret), appidp (client secret), m2m (`iam_m2m_token`); `auto` walks
13
+ appiam -> appidp -> m2m.
14
+ 2. `resolve_self_caller_headers()` — the self tenant-key routes. appidp and m2m present the
15
+ IAM-resource token (carrying `iam:self`) instead of the client secret / `iam_m2m_token`; appiam keeps
16
+ the app secret (its grant lives in `apps.scopes`). `auto` picks appiam when configured (without
17
+ invoking a token callable), else appidp only when the token's unverified `sub` is `client_id`, else
18
+ m2m.
19
+ 3. `admin_caller_headers()` — the tenant-administration routes: the IAM-resource token alone.
20
+
21
+ `resolve_scoped_token()` turns the configured or per-call `IamScopedTokenSource` into the token; on
22
+ the async surface a callable may return an awaitable. The SDK never mints that token, and no message
23
+ built here echoes a secret or a token.
24
+ @dependencies ._config (ResolvedConfig), ._errors (ConfigError), ._types
25
+ @relatedFiles _iam.py, _webhooks.py, _tenant_keys.py, _tenant_admin.py, ../../../js/src/caller.ts
26
+ @created 2026-10-03
27
+ @updated 2026-10-03 Pre-publish review: `auto` on the self routes decides appidp vs m2m from the token's
28
+ unverified `sub`; every token is checked with `is_header_safe()` before it becomes a header value.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import base64
33
+ import inspect
34
+ import json
35
+ from typing import Any, Optional
36
+
37
+ from memyauth._config import ResolvedConfig
38
+ from memyauth._errors import ConfigError
39
+ from memyauth._headers import is_header_safe
40
+ from memyauth._types import IamCallerMode
41
+
42
+ __all__ = [
43
+ "MISSING_CALLER_CREDENTIALS_MESSAGE",
44
+ "MISSING_SCOPED_TOKEN_MESSAGE",
45
+ "resolve_caller_headers",
46
+ "resolve_self_caller_headers",
47
+ "unverified_jwt_subject",
48
+ "is_header_safe",
49
+ "admin_caller_headers",
50
+ "resolve_scoped_token",
51
+ ]
52
+
53
+ MISSING_CALLER_CREDENTIALS_MESSAGE = "IAM caller credentials are not configured"
54
+ MISSING_SCOPED_TOKEN_MESSAGE = "an IAM-resource token is required (config iamScopedToken or a per-call scopedToken)"
55
+ _EMPTY_SCOPED_TOKEN_MESSAGE = "the IAM-resource token source returned no token"
56
+ _UNSAFE_SCOPED_TOKEN_MESSAGE = (
57
+ "the IAM-resource token has surrounding whitespace, a control character or a non-ASCII character"
58
+ )
59
+
60
+
61
+ def _has_appiam(config: ResolvedConfig) -> bool:
62
+ return bool(config.get("iam_app_id")) and bool(config.get("iam_app_secret"))
63
+
64
+
65
+ def _appiam_headers(config: ResolvedConfig) -> dict[str, str]:
66
+ if not _has_appiam(config):
67
+ raise ConfigError(MISSING_CALLER_CREDENTIALS_MESSAGE)
68
+ return {"X-AppIam-Id": config["iam_app_id"], "Authorization": f"Bearer {config['iam_app_secret']}"} # type: ignore[typeddict-item]
69
+
70
+
71
+ def resolve_caller_headers(config: ResolvedConfig, override_mode: Optional[IamCallerMode]) -> tuple[dict[str, str], str]:
72
+ """Returns EXACTLY ONE marker header (or none, for m2m) + the caller kind (§C.5, R6)."""
73
+ mode = override_mode or config["iam_caller_mode"]
74
+
75
+ def appidp() -> tuple[dict[str, str], str]:
76
+ if config.get("client_id") and config.get("client_secret"):
77
+ return (
78
+ {"X-AppIdp-Id": config["client_id"], "Authorization": f"Bearer {config['client_secret']}"},
79
+ "appidp",
80
+ )
81
+ raise ConfigError(MISSING_CALLER_CREDENTIALS_MESSAGE)
82
+
83
+ def m2m() -> tuple[dict[str, str], str]:
84
+ if config.get("iam_m2m_token"):
85
+ return {"Authorization": f"Bearer {config['iam_m2m_token']}"}, "m2m"
86
+ raise ConfigError(MISSING_CALLER_CREDENTIALS_MESSAGE)
87
+
88
+ if mode == "appiam":
89
+ return _appiam_headers(config), "appiam"
90
+ if mode == "appidp":
91
+ return appidp()
92
+ if mode == "m2m":
93
+ return m2m()
94
+
95
+ # auto: precedence appiam -> appidp -> m2m (memybase's order, §C.5).
96
+ if _has_appiam(config):
97
+ return _appiam_headers(config), "appiam"
98
+ if config.get("client_id") and config.get("client_secret"):
99
+ return appidp()
100
+ if config.get("iam_m2m_token"):
101
+ return m2m()
102
+ raise ConfigError(MISSING_CALLER_CREDENTIALS_MESSAGE)
103
+
104
+
105
+ def unverified_jwt_subject(token: str) -> Optional[str]:
106
+ """Decodes a JWT's payload WITHOUT verifying it and returns its `sub` — used only to pick which marker
107
+ header to send, never to trust anything (the provider verifies the token). `None` for an opaque or
108
+ malformed token. Mirrors the JS `unverifiedJwtSubject()`."""
109
+ parts = token.split(".")
110
+ if len(parts) != 3 or not parts[1]:
111
+ return None
112
+ try:
113
+ segment = parts[1] + "=" * (-len(parts[1]) % 4)
114
+ payload = json.loads(base64.urlsafe_b64decode(segment.encode("ascii")).decode("utf-8"))
115
+ except Exception: # noqa: BLE001 - any decoding failure means "not a JWT we can read"
116
+ return None
117
+ sub = payload.get("sub") if isinstance(payload, dict) else None
118
+ return sub if isinstance(sub, str) else None
119
+
120
+
121
+ def _appidp_scoped_headers(config: ResolvedConfig, token: str) -> dict[str, str]:
122
+ return {"X-AppIdp-Id": config["client_id"], "Authorization": f"Bearer {token}"}
123
+
124
+
125
+ async def resolve_self_caller_headers(config: ResolvedConfig, override_mode: Optional[IamCallerMode], source: Any) -> dict[str, str]:
126
+ """Caller headers for the self tenant-key routes. An explicit mode wins. `auto`: appiam when its row is
127
+ configured (no token callable is invoked); otherwise the IAM-resource token is resolved and its payload
128
+ decoded WITHOUT verification — appidp only when the token's `sub` equals `client_id` (the gateway
129
+ cross-checks exactly that), otherwise m2m. An OIDC web client's IAM-resource token normally comes from a
130
+ separate M2M application, so m2m is the usual outcome; the old `client_id ? appidp : m2m` rule could
131
+ never reach it (client_id is required) and made the default answer 401."""
132
+ mode = override_mode or config["iam_caller_mode"]
133
+ if mode == "appiam":
134
+ return _appiam_headers(config)
135
+ if mode in ("appidp", "m2m"):
136
+ if mode == "appidp" and not config.get("client_id"):
137
+ raise ConfigError(MISSING_CALLER_CREDENTIALS_MESSAGE)
138
+ token = await resolve_scoped_token(source)
139
+ return _appidp_scoped_headers(config, token) if mode == "appidp" else admin_caller_headers(token)
140
+ if _has_appiam(config):
141
+ return _appiam_headers(config)
142
+ if source is None:
143
+ raise ConfigError(MISSING_CALLER_CREDENTIALS_MESSAGE)
144
+ token = await resolve_scoped_token(source)
145
+ if config.get("client_id") and unverified_jwt_subject(token) == config["client_id"]:
146
+ return _appidp_scoped_headers(config, token)
147
+ return admin_caller_headers(token)
148
+
149
+
150
+ def admin_caller_headers(scoped_token: str) -> dict[str, str]:
151
+ """The tenant-administration routes: the IAM-resource token alone, no marker header."""
152
+ return {"Authorization": f"Bearer {scoped_token}"}
153
+
154
+
155
+ async def resolve_scoped_token(source: Any) -> str:
156
+ """Resolves an `IamScopedTokenSource`. A callable is invoked on every call; on the async surface it
157
+ may return an awaitable."""
158
+ if source is None:
159
+ raise ConfigError(MISSING_SCOPED_TOKEN_MESSAGE)
160
+ value = source() if callable(source) else source
161
+ if inspect.isawaitable(value):
162
+ value = await value
163
+ if not isinstance(value, str) or not value:
164
+ raise ConfigError(_EMPTY_SCOPED_TOKEN_MESSAGE)
165
+ if not is_header_safe(value):
166
+ raise ConfigError(_UNSAFE_SCOPED_TOKEN_MESSAGE)
167
+ return value