Metadata-Version: 2.4
Name: hawcx-oauth-client
Version: 2.2.0
Summary: Hawcx Python backend SDK — step-up flows, MFA enforcement, phone updates, and any /v1/management/* endpoint via delegation crypto
Author: Hawcx Team
License: MIT
Project-URL: Homepage, https://github.com/hawcx/hawcx-oauth-client
Project-URL: Documentation, https://github.com/hawcx/hawcx-oauth-client#readme
Project-URL: Repository, https://github.com/hawcx/hawcx-oauth-client
Project-URL: Issues, https://github.com/hawcx/hawcx-oauth-client/issues
Keywords: hawcx,authentication,mfa,step-up,delegation
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: cryptography>=46.0.7
Requires-Dist: requests>=2.33.0
Requires-Dist: httpx<1.0,>=0.27.0
Requires-Dist: pyjwt[crypto]>=2.12.0
Provides-Extra: dev
Requires-Dist: pytest>=9.0.3; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: pytest-cov>=4.0; extra == "dev"
Requires-Dist: pytest-mock>=3.10; extra == "dev"
Requires-Dist: respx>=0.20; extra == "dev"
Requires-Dist: responses>=0.23; extra == "dev"
Requires-Dist: mypy>=1.0; extra == "dev"
Requires-Dist: ruff>=0.1; extra == "dev"

# hawcx-oauth-client

Customer-backend SDK for Hawcx's [management API](https://docs.hawcx.com)
(`/v1/management/*`) — step-up authentication flows, MFA enforcement, phone
updates, and policy lookups. Authenticate with your existing OIDC
`private_key_jwt` signing key via `StepUpClient.with_private_key_jwt` (one
key, standards-based RFC 7523 Bearer JWT). A legacy ECIES path (`Hawcx`,
`from_secret_key`) is retained for existing deployments and will be removed
in a future major version.

**For OIDC login/signup**, the SDK also ships a relying-party client —
`HawcxOAuth` (sync) / `HawcxOAuthAsync` (async) — with OIDC discovery,
authorization-code exchange, id_token verification, and optional
`private_key_jwt` client authentication. See
[Confidential clients (`private_key_jwt`)](#confidential-clients-private_key_jwt)
below. (A standard OIDC library such as [authlib](https://docs.authlib.org/)
also works against Hawcx's discovery URL if you prefer.)

## Confidential clients (`private_key_jwt`)

If your project is registered in the Hawcx admin console with **Client
authentication = Signing key (`private_key_jwt`)**, attach an Ed25519 signer.
The SDK signs an RFC 7523 `client_assertion` on every token exchange (PKCE is
still sent). Public clients omit the signer and keep using PKCE only.

```python
import json, os
from hawcx_oauth_client import HawcxOAuth, ClientAssertionSigner

private_jwk = json.loads(os.environ["HAWCX_PRIVATE_JWK"])  # OKP/Ed25519, with kid

oauth = HawcxOAuth.from_issuer(
    "https://dev-demo-api.hawcx.com",
    os.environ["HAWCX_CONFIG_ID"],
    os.environ["HAWCX_CLIENT_ID"],
).with_client_assertion(ClientAssertionSigner.ed25519_from_jwk(private_jwk))

result = oauth.exchange_code(code, code_verifier)   # PKCE + signed assertion
print(result.claims["sub"])
```

`HawcxOAuthAsync` exposes the same API with `await` (and `await
HawcxOAuthAsync.from_issuer(...)`). The private key must be Ed25519
(`kty="OKP", crv="Ed25519"`, with a `kid`); its public JWK is what you register
in the admin console. The assertion's `aud` is the discovered `token_endpoint`,
and EdDSA is the only accepted algorithm. `client_id` becomes the assertion
`iss`/`sub` and the id_token `aud`. If you bound a `nonce` at `/authorize`, pass
`expected_nonce=...` to `exchange_code` — verification refuses a nonce-bearing
token when none is supplied (OIDC Core §3.1.3.7).

## OIDC discovery (issuer mode)

`HawcxOAuth.from_issuer(issuer, config_id, client_id)` fetches
`{issuer}/.well-known/openid-configuration` once. Besides `exchange_code` and
`verify_token`, the client builds the browser-facing URLs for you (no I/O):

```python
from hawcx_oauth_client import HawcxOAuth

oauth = HawcxOAuth.from_issuer("https://api.hawcx.com", config_id, client_id)

login_url = oauth.build_authorize_url(
    redirect_uri="https://app.example.com/callback",
    state=state,
    code_challenge=code_challenge,   # S256 of your code_verifier
    nonce=nonce,                     # recommended; pass it back as expected_nonce
    # scope="openid" (default), prompt=None, extra_params=None
)
logout_url = oauth.build_end_session_url(
    post_logout_redirect_uri="https://app.example.com/",   # all optional
    id_token_hint=id_token,
    state=state,
)
```

`build_authorize_url` sends `response_type=code`, `client_id`, `redirect_uri`,
`scope`, `state`, `nonce` (if given), `code_challenge`,
`code_challenge_method=S256` and `prompt` (if given). `redirect_uri`, `state`
and `code_challenge` are required (empty or whitespace-only raises
`ValueError`); a blank `nonce`/`prompt` is omitted and a blank `scope` becomes
`openid`. `extra_params` (string keys and values) adds other parameters but
cannot override these, `request` or `request_uri` (`ValueError`).
`build_end_session_url` always sends `client_id`; blank optional values are
omitted. If discovery has no secure `authorization_endpoint` /
`end_session_endpoint` (https, no userinfo or `#`), or its query already sets a
parameter the helper sends, the helper raises `DiscoveryError`
(`error="invalid_discovery_document"`).

### Custom domain

If you serve Hawcx sign-in on your own domain (e.g. `auth.example.com`, a DNS
CNAME to Hawcx), pass `custom_domain` so the browser URLs use it. Only the browser URLs
move: token exchange, JWKS and the id_token `iss` check stay on the issuer.

```python
oauth = HawcxOAuth.from_issuer(
    "https://api.hawcx.com", config_id, client_id,
    custom_domain="https://auth.example.com",
)
oauth.build_authorize_url(redirect_uri=..., state=..., code_challenge=..., nonce=nonce)
# -> https://auth.example.com/authorize?response_type=code&client_id=...
oauth.build_end_session_url(post_logout_redirect_uri="https://app.example.com/")
# -> https://auth.example.com/oauth2/end_session?client_id=...
result = oauth.exchange_code(code, code_verifier, expected_nonce=nonce)   # still POSTs to api.hawcx.com
```

`custom_domain` must be an origin `scheme://host[:port]` with an optional single
trailing `/`: https (http only for loopback), no userinfo, path, query,
fragment, whitespace, `\` or `%`. The host must be an IPv4 dotted quad, a
bracketed IPv6 literal, or an ASCII hostname (IDN as punycode `xn--...`; no
`*`, `_`, empty labels or trailing dot; the last label starts with a letter).
The port, if given, is 1-65535 without leading zeros. The value is used in
canonical form (lower case, default port dropped). Anything else, including
`""`, raises `InvalidCustomDomainError` (a `ValueError` and `HawcxOAuthError`
with `error="invalid_custom_domain"`) before discovery runs; `None` means
unset. The SDK only checks the form: `custom_domain` must also be the
TXT-verified domain registered for your client's own tenant, set up as a DNS
CNAME to Hawcx (Hawcx serves its certificate). Another tenant's verified domain is always refused; when the tenant has
custom-domain enforcement on, every host other than its own verified domains
(including IP literals and single-label hosts) is refused too. The discovered endpoint's path and query are kept verbatim; only
scheme, host and port are replaced. `HawcxOAuthAsync` takes the same argument
and exposes the same two (non-async) helpers.

## Profile service (`ProfileClient`)

Read and edit user profiles, consents and the audit trail held by the Hawcx
profile service. Backend only: it needs a confidential (`private_key_jwt`)
client, built from the same `HawcxOAuth` you use for login.

```python
from hawcx_oauth_client import ClientAssertionSigner, HawcxOAuth, ProfileApiError, ProfileClient

oauth = HawcxOAuth.from_issuer(issuer, config_id, client_id).with_client_assertion(
    ClientAssertionSigner.ed25519_from_jwk(private_jwk)
)

with ProfileClient(oauth) as profiles:          # base URL defaults to {issuer}/profile
    # Act as your backend (client_credentials): any user in the tenant.
    alice = profiles.get_user("alice")
    profiles.update_user("alice", {"display_name": "Alice", "locale": None})  # None clears
    for user in profiles.iter_users(status="active"):
        ...
    profiles.set_consent("alice", "marketing", {"status": "granted"})

    # Act for the user who just signed in (token exchange of their ID token,
    # profile:self). Use the ID token exchange_code just returned.
    me = profiles.for_user(result.id_token)
    me.get_me()
    me.update_me({"given_name": "Al"})
```

`AsyncProfileClient((await HawcxOAuthAsync.from_issuer(...)).with_client_assertion(...))`
has the same methods with `await`, `async for` iterators and `async with`.

Client configuration the OP needs:

- machine calls: the client's `allowed_scopes` include the scopes it uses
  (`users:read`, `users:write`, `users:delete`, `consent:read`, `consent:write`,
  `audit:read`). By default every allowed scope is requested; pass
  `scopes=[...]` to narrow it (an empty list raises `ValueError`: omit
  `scopes` for the default, or pass at least one).
- `for_user`: `token_exchange_scopes: ["profile:self"]`, and the ID token must
  have been issued to this same client.

`for_user` is for the moment right after sign-in: the OP caps the delegated
token's lifetime at the ID token's, and ID tokens live 60 s by default, so a
stored ID token stops working soon after login. For later work on a user's
profile (in a later request, a job, an admin action) use the machine methods
with their `user_id`, which is the ID token's `sub` claim. Re-exchanging the
same ID token cannot extend that lifetime, so a `for_user` token is renewed at
expiry only, never early.

Tokens: the machine token is renewed early (60 s, or half its lifetime, before
it expires; the lifetime counts from when the token request was sent). A
caller holding a still-valid token never waits for that renewal; if it fails,
for any reason, the cached token keeps serving and the next early renewal
waits 30 s (or until expiry, if sooner). When the cache is empty or expired,
callers that waited for a fetch that failed less than a second ago get that
failure instead of each trying again.

Errors: profile-service failures raise `ProfileApiError` (`status`, `title`,
`detail`, `type`, `instance`, `request_id`, `retry_after`, `missing`,
`fields`, parsed `body`, and `raw_body`; `status == 0` for network errors
with no response).
`raw_body` is the response text capped at 4096 characters, kept for diagnosis
and never in `str(err)`. It is set for non-2xx responses and for a 2xx body
that is not JSON but a markup page (starts with `<`, e.g. a gateway's HTML).
It is `None` for an empty body, a network error, and any other 2xx body (a
mangled profile may be personal data). Text fields are stripped; non-string or blank ones are
`None`, and `missing`/`fields` are `None` unless lists of strings. A Kong
body (`{"message": ..., "request_id": ...}` with no `title`/`detail`) puts
`message` in `detail`, and its `request_id` is used when the `X-Request-Id`
header is missing or blank. A Kong HTML 502 gives `str(err) == "502"` with
the page in `raw_body`. An error whose body fails to read (cut off
mid-read, or a bad `Content-Encoding`) keeps its status. A 2xx whose body
fails to read is retried like a network error and for DELETE counts as "sent
with no HTTP response"; if it is the final failure it keeps the 2xx status
(title `Response body read failed`), because the call ran: a `create_user`
answered 201 created the profile even though its body was lost.

A 2xx body that is not JSON raises `ProfileApiError` titled `Invalid JSON
response`; valid JSON of the wrong shape, `Unexpected response body` (detail
`None`). A 204 is accepted only by `delete_user`; for any call that returns a
value it raises `Unexpected response body`. Any 2xx answer to DELETE is
success, whatever its body (empty, not JSON, or not a JSON object).
A wrong-typed field in a 2xx body (say `null` for a bool) takes the model's
default; the wire value stays in `raw`. A `next_cursor` of `""` means no
next page (`None`), as null or a missing one does.

Token failures raise `TokenExchangeError`. GET/PUT/DELETE are retried on
408/429/502/503/504 and network errors (honouring `Retry-After`); POST/PATCH
are never retried. Token-endpoint network errors (including a 2xx body that
fails to read) and 408/429/502/503/504 are retried for every method (no
profile request was sent) within the same budget, using backoff only:
`Retry-After` from the token endpoint is ignored. A 401 refreshes the token
and replays the request once; for a `for_user` client whose re-exchange then
fails for good (e.g. `invalid_grant`, the ID token expired), the original
401 is raised as `ProfileApiError`, not the token error. Redirects are never followed. `create_user`
refuses a `user_id` that `get_user` could not address (`ValueError` naming
`user_id`, before any request).

`delete_user` raises on 404 unless an earlier attempt of the same call may
have run: it was sent but got no HTTP response, or got 504. A failed connect,
pool timeout, unsupported URL scheme, a request httpx refused to write, or a
proxy tunnel failure means nothing was sent. A 502 does not count either:
it is ambiguous (Kong/nginx send it when they could not reach the upstream,
and also when the upstream closed mid-request), but a false "deleted" is
worse than a false 404. Once set, the
rule holds for the rest of the call whatever answers come in between;
token-endpoint failures never set it.

## Installation

```bash
pip install hawcx-oauth-client
```

Requires Python 3.10+.

## Quick start

**New deployments:** prefer `StepUpClient.with_private_key_jwt` — see
[Advanced — StepUpClient](#advanced----stepclient) below.

**Existing deployments** using the `hwx_sk_v1_...` credential blob can use
the `Hawcx` facade (note: this path is deprecated and will be removed in a
future major version):

```python
import os
from hawcx_oauth_client import Hawcx

hawcx = Hawcx(
    config_id=os.environ["HAWCX_CONFIG_ID"],   # your tenant's API key
    secret_key=os.environ["HAWCX_SECRET_KEY"], # hwx_sk_v1_... blob (legacy ECIES)
    base_url="https://api.hawcx.com",
)

# Begin a step-up flow to change a user's MFA method.
result = hawcx.start_step_up(
    user_id="alice@example.com",
    purpose="change_mfa_method",
    new_mfa_method="email_otp",
)
print(result.start_token)    # JWT, hand to your frontend
print(result.expires_in)     # seconds until it expires (~60)

# After the user completes the MFA challenge in the browser, your frontend
# returns a receipt — finalize the change:
hawcx.consume_step_up(receipt=user_receipt)
```

## What this SDK is for

A Hawcx **customer backend** uses this SDK to perform privileged operations
on its users (revoke MFA, change phone, force MFA enrollment, etc.) without
asking the user to re-authenticate from scratch. The protocol underneath
("delegation") ensures these operations can only happen if your backend
proves it holds the customer's private signing key.

| Use case | Use this SDK? |
|---|---|
| Basic login/signup in your customer-facing app | ❌ — use any OIDC client (`authlib`, `oauthlib`) |
| Step up a user mid-session to re-verify before a sensitive action | ✅ |
| Set or read a user's MFA enforcement preference | ✅ |
| Change a user's stored phone number | ✅ |
| Look up tenant signin policy from your backend | ✅ |
| Bulk-provision users from your IdP into Hawcx | ❌ — use Hawcx's SCIM endpoint with any SCIM client |

## API

### `Hawcx`

The primary client. Two values to construct it:

- `config_id` — your tenant identifier (the API key Hawcx hands out at provisioning).
- `secret_key` — the `hwx_sk_v1_...` credential blob.

```python
hawcx = Hawcx(
    config_id="your-tenant-id",
    secret_key="hwx_sk_v1_...",
    base_url="https://api.hawcx.com",   # required
    # All optional:
    api_prefix="/v1",
    extra_headers={},
    timeout_seconds=15,
    clock_skew_seconds=300,
)
```

**Methods:**

```python
# Step-up: change MFA method.
hawcx.start_step_up(
    user_id="alice@example.com",
    purpose="change_mfa_method",
    new_mfa_method="email_otp",   # one of: email_otp, sms_otp, totp
)

# Step-up: change phone number.
hawcx.start_step_up(
    user_id="alice@example.com",
    purpose="change_phone_number",
    new_phone_number="+15551234567",
)

# Step-up: finalize after the user completes the challenge.
hawcx.consume_step_up(receipt="...")

# Generic management endpoint. Use for anything under /v1/management/*.
hawcx.management(
    "/v1/management/users/mfa-enforcement",
    {"userid": "alice@example.com"},                                 # read mode
)
hawcx.management(
    "/v1/management/users/mfa-enforcement",
    {"userid": "alice@example.com", "mfa_enforcement": "always_on"}, # set mode
)
```

### `HawcxAsync`

Same surface, awaitable methods. Supports `async with` for clean HTTP-client teardown.

```python
import asyncio
from hawcx_oauth_client import HawcxAsync

async def main():
    async with HawcxAsync(
        config_id=os.environ["HAWCX_CONFIG_ID"],
        secret_key=os.environ["HAWCX_SECRET_KEY"],
        base_url="https://api.hawcx.com",
    ) as hawcx:
        result = await hawcx.start_step_up(
            user_id="alice@example.com",
            purpose="change_mfa_method",
            new_mfa_method="email_otp",
        )

asyncio.run(main())
```

## Errors

All exceptions inherit from `HawcxOAuthError`:

| Exception | When |
|---|---|
| `DelegationCryptoError` | Invalid blob, key length wrong, signature verification failed locally |
| `DelegationRequestError` | Network failure, non-2xx response (carries `status_code` + `response_body`) |
| `DelegationResponseError` | Hawcx response missing signature, clock skew exceeded, decryption failed |

```python
from hawcx_oauth_client import DelegationRequestError

try:
    hawcx.start_step_up(user_id=..., purpose="change_mfa_method", new_mfa_method="email_otp")
except DelegationRequestError as e:
    if e.status_code == 404:
        print("user not found")
    else:
        raise
```

## Advanced — `StepUpClient`

For callers who need lower-level control (custom headers, alternate API
prefix, direct transport access), `StepUpClient` and `StepUpClientAsync` are
also exported. `Hawcx` is a thin facade over them; reach for `StepUpClient`
directly when you need these options.

### Preferred: `with_private_key_jwt`

Reuses your existing OIDC `private_key_jwt` signing key — one Ed25519 key
for both OIDC login and management API calls. Each request is authenticated
with a per-request signed Bearer JWT (RFC 7523); no ECIES blob required.

```python
import os
from hawcx_oauth_client import StepUpClient

client = StepUpClient.with_private_key_jwt(
    oidc_signing_key=os.environ["HAWCX_OIDC_PRIVATE_KEY_PEM"],  # Ed25519 PEM or bytes
    kid="your-key-id",          # registered in the Hawcx admin console
    client_id=os.environ["HAWCX_CLIENT_ID"],
    base_url="https://api.hawcx.com",
    config_id=os.environ["HAWCX_CONFIG_ID"],
    # Optional:
    api_prefix="/v1",
    relying_party=None,
    extra_headers={},
    jwt_ttl_seconds=60,
    jwks_url=None,              # default: {base_url}/.well-known/jwks.json
)

client.start_token(user_id="alice@example.com", purpose="change_mfa_method", new_mfa_method="email_otp")
client.consume_receipt(receipt=user_receipt)
client.management_request(endpoint="/v1/management/users/mfa-enforcement", payload={"userid": "alice@example.com"})
```

Every 2xx response is verified before it is returned: hx_auth signs it with an
`X-Response-Signature` JWS (ES256/384/512, the issuer's OAuth signing key) that
binds the SHA-256 of the exact response bytes, and the SDK checks it against the
issuer JWKS (cached; an unknown `kid` refetches at most once per 30s). A missing or invalid
signature raises `ResponseSignatureError` (a `DelegationResponseError`).
`verify_response_signature=False` turns this off. It is an emergency escape hatch
only, and it logs a warning.

### Legacy (deprecated): ECIES via `from_secret_key`

Existing deployments using the `hwx_sk_v1_...` credential blob can continue
using `from_secret_key` or `from_keys`, but these factories are deprecated
and will be removed in a future major version. Migrate to
`with_private_key_jwt` when possible.

```python
from hawcx_oauth_client import StepUpClient

client = StepUpClient.from_secret_key(   # DeprecationWarning is emitted
    secret_key="hwx_sk_v1_...",
    base_url="https://api.hawcx.com",
    api_key="your-config-id",
    tenant_header_name="X-Config-Id",
    tenant_header_value="your-config-id",
    extra_headers={"X-Custom": "..."},
)
client.start_token(...)
client.consume_receipt(...)
client.management_request(...)
```

## Migration from 0.x

The 0.x line had two paths that are both removed in 1.x:

- **OAuth code-exchange path** (`exchange_code_for_claims`, `verify_jwt`,
  `require_oauth_claims`): replaced by any standard OIDC client pointing at
  Hawcx's discovery URL. Example with `authlib`:

  ```python
  from authlib.integrations.requests_client import OAuth2Session
  session = OAuth2Session(client_id=tenant_id, code_verifier=verifier)
  token = session.fetch_token(
      "https://api.hawcx.com/oauth2/token",
      code=code,
      headers={"X-Config-Id": tenant_id},
  )
  ```

- **`HawcxDelegationClient`** (`list_user_devices`, `revoke_device`,
  `initiate_mfa_change`, `set_suggested_mfa`, etc.): those endpoints
  (`/hc_auth/v5/*`) were removed from `hx_auth` long before this release.
  Use `hawcx.management(...)` with the current `/v1/management/*` endpoints.

See [CHANGELOG.md](CHANGELOG.md) for the full breaking-change list.

## Examples

For an end-to-end FastAPI backend mirroring the prod-validated
`hawcx_web_demo/backend/src/server.ts` in Python, see
[`EXAMPLE_USAGE.md`](EXAMPLE_USAGE.md).

## Security model

Every request to `/v1/management/*` is:

1. **JSON-serialized** then **ECIES-encrypted** with Hawcx's X25519 public key.
   Body is unreadable to anything between your process and Hawcx's hx_auth
   service — including Kong, load balancers, and observability sidecars.
2. **Signed** with your Ed25519 private key over the encrypted body plus a
   timestamp. Hawcx verifies with your public key (which it stores; your
   private key never leaves your environment).
3. **Replay-protected**: Hawcx rejects signed messages older than 5 minutes.

Responses follow the same protocol in reverse — signed by Hawcx, encrypted
to your X25519 public key. The SDK verifies the signature, checks the
timestamp, then decrypts. If any step fails, you get a `DelegationResponseError`.

Compare with Hawcx's other auth doors:

| Door | Auth | What for |
|---|---|---|
| `/oauth2/token` | `X-Config-Id` header (Kong API key) | Standard OIDC code exchange |
| `/v1/scim/{tid}/Users` | Bearer token | Provisioning from external IdPs (Entra, Okta) |
| `/v1/management/*` | **Delegation crypto (this SDK)** | Customer-backend operations on users |

## License

MIT
