Metadata-Version: 2.4
Name: hawcx-oauth-client
Version: 1.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 :: 5 - Production/Stable
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
License-File: LICENSE
Requires-Dist: cryptography>=46.0.7
Requires-Dist: requests>=2.33.0
Requires-Dist: httpx>=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"
Dynamic: license-file

# 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. Wraps Hawcx's delegation protocol (Ed25519 +
ECIES) behind a single `Hawcx` client.

**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).

## Installation

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

Requires Python 3.10+.

## Quick start

```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
    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 and exists because most
customers don't need that flexibility. Reach for these only if you do.

```python
from hawcx_oauth_client import StepUpClient

# Same behaviour as Hawcx, but with every option exposed:
client = StepUpClient.from_secret_key(
    secret_key="hwx_sk_v1_...",
    base_url="https://api.hawcx.com",
    api_key="tenant-a",
    tenant_header_name="X-Config-Id",
    tenant_header_value="tenant-a",
    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
