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.
- memydev_auth_sdk-0.2.0/PKG-INFO +145 -0
- memydev_auth_sdk-0.2.0/README.md +128 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/pyproject.toml +1 -1
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/scripts/gen_sync.py +16 -2
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/__init__.py +76 -3
- memydev_auth_sdk-0.2.0/src/memyauth/_caller.py +167 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_client.py +20 -2
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_config.py +22 -0
- memydev_auth_sdk-0.2.0/src/memyauth/_contract.py +65 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_errors.py +85 -1
- memydev_auth_sdk-0.2.0/src/memyauth/_headers.py +26 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_iam.py +115 -48
- memydev_auth_sdk-0.2.0/src/memyauth/_iam_shapes.py +405 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_oidc.py +15 -1
- memydev_auth_sdk-0.2.0/src/memyauth/_sync/_caller.py +169 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_client.py +20 -2
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_iam.py +115 -48
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_oidc.py +15 -1
- memydev_auth_sdk-0.2.0/src/memyauth/_sync/_tenant_admin.py +379 -0
- memydev_auth_sdk-0.2.0/src/memyauth/_sync/_tenant_keys.py +214 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_transport.py +25 -4
- memydev_auth_sdk-0.2.0/src/memyauth/_sync/_webhooks.py +105 -0
- memydev_auth_sdk-0.2.0/src/memyauth/_tenant_admin.py +377 -0
- memydev_auth_sdk-0.2.0/src/memyauth/_tenant_keys.py +212 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_transport.py +25 -4
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_types.py +213 -3
- memydev_auth_sdk-0.2.0/src/memyauth/_webhook_receiver.py +164 -0
- memydev_auth_sdk-0.2.0/src/memyauth/_webhooks.py +103 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/sync/__init__.py +73 -2
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_client.py +14 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_config.py +25 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_contract_parity.py +77 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_errors.py +42 -0
- memydev_auth_sdk-0.2.0/tests/test_iam_lookups.py +162 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_oidc.py +18 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_sync_parity.py +12 -1
- memydev_auth_sdk-0.2.0/tests/test_tenant_admin.py +352 -0
- memydev_auth_sdk-0.2.0/tests/test_tenant_keys.py +365 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_transport.py +43 -0
- memydev_auth_sdk-0.2.0/tests/test_webhooks.py +251 -0
- memydev_auth_sdk-0.1.0/PKG-INFO +0 -86
- memydev_auth_sdk-0.1.0/README.md +0 -69
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/.gitignore +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/requirements-release.in +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/requirements-release.lock +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_discovery.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_idtoken.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_pkce.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_roles.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_session.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_state.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/__init__.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_discovery.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/_sync/_idtoken.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/src/memyauth/py.typed +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/conftest.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_discovery.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_iam.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_idtoken.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_pkce.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_roles.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_session.py +0 -0
- {memydev_auth_sdk-0.1.0 → memydev_auth_sdk-0.2.0}/tests/test_state.py +0 -0
- {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
|
+
```
|
|
@@ -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
|
|
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 = [
|
|
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
|
|
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`
|
|
9
|
-
both surfaces (no twin exists or
|
|
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
|