Metadata-Version: 2.4
Name: memydev-auth-sdk
Version: 0.2.0
Summary: Official MemyAuth (OIDC + IAM) client SDK for Python
License: Proprietary and unlicensed pending approved service terms.
Requires-Python: >=3.11
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pyjwt[crypto]<3,>=2.9
Provides-Extra: dev
Requires-Dist: editables==0.5; extra == 'dev'
Requires-Dist: hatchling==1.27.0; extra == 'dev'
Requires-Dist: pytest-asyncio==0.24.0; extra == 'dev'
Requires-Dist: pytest==8.3.5; extra == 'dev'
Requires-Dist: respx==0.22.0; extra == 'dev'
Requires-Dist: unasync==0.6.0; extra == 'dev'
Description-Content-Type: text/markdown

# memydev-auth-sdk

Official Python client for **MemyAuth** — the estate's OIDC + IAM provider. Import name: `memyauth`.

```python
import asyncio
from memyauth import MemyAuth

async def main() -> None:
    async with MemyAuth(
        issuer_url="https://auth.memy.dev/oidc",
        client_id="my-app",
        client_secret="...",
        public_base_url="https://my-app.example.com",
        id_token_validation="local-jwks",   # or "tls-token-endpoint" — see below, no default
    ) as auth:
        req = await auth.oidc.build_authorization_url()
        # redirect the browser to req.url, persist req via auth.state.sign(...)
        ...

asyncio.run(main())
```

A synchronous facade is available for non-async callers:

```python
from memyauth.sync import MemyAuth

with MemyAuth(issuer_url=..., client_id=..., client_secret=..., id_token_validation="local-jwks") as auth:
    tokens = auth.oidc.exchange_code(code=..., code_verifier=...)
```

## IAM: identity, tenants, webhooks, tenant keys, tenant administration

Every relying-party operation of the IAM API (2.8.0) is covered. Projections are snake_case dicts with
the documented fields only (unknown wire fields are dropped, required ones are checked).

```python
async with MemyAuth(
    issuer_url="https://auth.memy.dev/oidc",
    client_id="my-app",
    client_secret="...",
    public_base_url="https://my-app.example.com",
    id_token_validation="local-jwks",
    iam_app_id="my-app",              # appiam caller credential (X-AppIam-Id + secret)
    iam_app_secret="...",
    iam_scoped_token=get_iam_token,   # IAM-resource token: a string or a (coroutine) function
) as auth:
    session = await auth.iam.me(user_token)                       # GET /iam/me
    tenant = await auth.iam.lookup_tenant(code="acme")            # GET /iam/tenants
    ready = await auth.iam.ready()                                 # {"ready": True, "status": "ok", ...}

    keys = await auth.tenant_keys.list_keys(user_token)            # the signed-in owner's own tenant
    created = await auth.tenant_keys.create_key(user_token, label="primary", rotate={"grace_seconds": 3600})
    await auth.tenant_keys.update_key(user_token, key_id, expires_at=None)   # None clears; omit to keep

    page = await auth.tenant_admin.list_tenants(code_prefix="ac", limit=50)  # in-cluster only
    await auth.tenant_admin.rotate_keys(tenant_id, 0, request_id="ops-2026-10-03-001")  # break-glass, no value returned
    audit = await auth.tenant_admin.list_audit(tenant_id=tenant_id, action="rotate")  # who changed what, newest first

    config = await auth.webhooks.register(webhook_uri="https://my-app.example.com/iam-webhook")
    secret = config.get("signing_secret")                          # present once — store it now
```

- **Self tenant keys** need the `iam:self` grant. An appiam app holds it in IAM (`create-app --scopes
  iam:self`); an appidp/m2m caller presents an IAM-resource token carrying `iam:self` (`iam_scoped_token`,
  or `scoped_token=` per call) instead of its client secret. In `auto` mode `X-AppIdp-Id` is sent only when
  the token's (unverified) `sub` is your `client_id`; otherwise the token goes alone (m2m).
- Every `tenant_id` / `key_id` must be a UUID, and every token or secret printable ASCII without surrounding
  whitespace — both checked before any request; errors never echo the value.
- **Tenant administration** presents the IAM-resource token alone, with `iam:tenants:*` / `iam:keys:*`.
- **The SDK never mints that token.** Mint it with the client-credentials grant, `resource=IAM_API_RESOURCE`
  and the scopes your Logto role grants; pass a function to refresh it — it is called on every request
  that needs it.
- Errors: `TenantKeyConflictError` (`.reason` `last_key` / `max_live`), `TenantAdminConflictError`
  (`code_taken`), `ScopeForbiddenError` (`.required_scopes`), `GoneError` for a deprovisioned tenant looked
  up by id. Live keys are returned with their value only on the owner's own routes; the SDK never logs a
  key or puts one in an error message.

Receiving IAM webhooks:

```python
from memyauth import is_tenant_key_event, parse_tenant_event, parse_webhook_envelope, verify_webhook_signature

if not verify_webhook_signature(raw_body, headers.get("X-IAM-Webhook-Signature"), signing_secret):
    return 401
event = parse_webhook_envelope(raw_body)   # dedupe on event["id"]
if is_tenant_key_event(event):             # Tenant.KeyCreated / KeyRotated / KeyRevoked / Deprovisioned
    change = parse_tenant_event(event)     # actor_kind, key_id, key_prefix, retired_keys … — never a key value
```

## Choosing `id_token_validation`

MemyAuth signs ID tokens with **ES384 only**. Two ID-token trust strategies are both in production
use across the estate and neither is a silent default — you must choose:

- `local-jwks` (**recommended for new consumers**): verifies the ID token's signature locally
  against the provider's JWKS, with an explicit algorithm allow-list (`["ES384"]` by default).
  Stateless, no extra round trip, defends against ID-token substitution.
- `tls-token-endpoint`: performs **no** local signature verification; identity is derived from the
  direct TLS channel to the token endpoint (OIDC Core §3.1.3.7 item 6) plus `/oidc/me` or
  `/iam/me`. Choose this only when you never process the ID token directly.

An `allowed_algorithms` pin that cannot verify any token this provider issues (e.g. `["RS256"]`)
fails loudly at construction with `UnsupportedAlgorithmPinError` — this SDK exists in large part to
make that historically-real defect (shipped twice against this provider) impossible to repeat
silently.

## Async is the source of truth

`memyauth/_*.py` are hand-written and authoritative. `memyauth/_sync/*.py` and the `memyauth.sync`
facade are **mechanically generated** from them by `scripts/gen_sync.py` (via `unasync`) — never
hand-edit `_sync/`. Regenerate after any async change:

```
python scripts/gen_sync.py            # write the twins
python scripts/gen_sync.py --check    # verify no drift; exits non-zero if the twins are stale
```

## Development

```
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install hatchling==1.27.0 editables==0.5
.venv/bin/python -m pip install --no-build-isolation -e ".[dev]"
.venv/bin/python -m pytest -q
```
