Metadata-Version: 2.5
Name: decane
Version: 1.0.0
Summary: Server-side SDK for Decane Connect Kit: verify access tokens, sign users in, read per-user records, create users and wallets. The Python port of decane-node.
Project-URL: Homepage, https://kit.decane.app
Project-URL: Documentation, https://kit.decane.app/llms-python.txt
Project-URL: Repository, https://github.com/Korex23/decane-connect-kit
License-Expression: MIT
Keywords: auth,decane,jwks,jwt,oauth,otp,signin,verify,wallet,web3
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pyjwt[crypto]>=2.8
Description-Content-Type: text/markdown

# decane

Server-side SDK for **Decane Connect Kit**, for Python backends. Verify a
caller's Decane access token, resolve their wallet addresses, and, with a
developer API key, sign users in from the server. No app secret required.
The Python port of [`decane-node`](https://www.npmjs.com/package/decane-node):
same endpoints, same error codes, same tests. Two verification modes:

- **Static verification key** (offline): verify locally against your
  project's ES256 public key. No network call.
- **JWKS** (default): Decane publishes an ES256 JWKS that this package
  fetches, caches and refetches on rotation.

```bash
pip install decane
```

Python 3.10+. Dependencies: `httpx` and `PyJWT[crypto]`. Typed (`py.typed`).

## Quick start

```python
import os
from decane import DecaneClient, DecaneAuthError

# Static key (recommended for backends): copy the key from your Decane dashboard.
# app_id is checked against the token's project_id; verification_key is also
# read from the DECANE_VERIFICATION_KEY env var if omitted.
decane = DecaneClient(
    app_id=os.environ["DECANE_APP_ID"],
    verification_key=os.environ["DECANE_VERIFICATION_KEY"],  # ES256 SPKI PEM
)

# In your auth middleware:
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
claims = decane.verify_access_token(token)  # raises DecaneAuthError if invalid
claims.user_id  # stable Decane user id; key your records off this
```

To use JWKS instead, omit `verification_key`:

```python
decane = DecaneClient(app_id=os.environ["DECANE_APP_ID"])

# Base URL resolution: `api_base` option → `DECANE_API_BASE` env → the built-in
# https://backend.decane.app. An empty value at any step counts as unset, so a
# deploy template that writes `DECANE_API_BASE=` cannot break the client. The
# resolved value is readable as `decane.api_base`.
```

Construct one client per project and reuse it: the JWKS is fetched lazily,
cached for ten minutes, and refetched once (with a 30 s cooldown) when a
token's signature matches no held key, so key rotation needs no redeploy.

### Async

`AsyncDecaneClient` has the same constructor and the same methods, each
awaitable, over `httpx.AsyncClient`:

```python
from decane import AsyncDecaneClient

decane = AsyncDecaneClient(app_id=os.environ["DECANE_APP_ID"])
claims = await decane.verify_access_token(token)
```

Both clients are context managers (`with` / `async with`) and close the
`httpx` client they made; one you inject is yours to close.

### Fail-closed middleware

```python
claims = decane.safe_verify_access_token(token)  # None instead of raising
if claims is None:
    return JSONResponse({"error": "unauthorized"}, status_code=401)
```

### Resolve the user's wallet addresses

```python
user = decane.get_user(token)
user.id                # uid
user.addresses.evm     # "0x…" or None
user.addresses.solana
user.addresses.tron
user.linked_accounts   # Privy-compatible: (LinkedAccount(type="wallet", chain_type="ethereum", address=…), …)
```

### Standalone (drop-in for `@privy-io/node`'s `verifyAccessToken`)

```python
from decane import verify_access_token
claims = verify_access_token(token, app_id=os.environ["DECANE_APP_ID"])
```

## Sign-in (server-side)

The same sign-in flows the frontend SDKs offer, minus anything that needs a
browser or a device authenticator. Requires your **developer API key**
(`dck_live_…`, from the Decane dashboard): pass `api_key` or set the
`DECANE_API_KEY` env var. **The key is a secret**: server env only, never
shipped to a browser.

```python
decane = DecaneClient(
    app_id=os.environ["DECANE_APP_ID"],
    api_key=os.environ["DECANE_API_KEY"],
)

# Email OTP, two steps
decane.connect_with_email("user@example.com")                       # emails a 6-digit code
session = decane.verify_email_code("user@example.com", "123456")
session.access_token  # Decane JWT (8 h); verify it, or hand it to the client
session.user_id       # stable Decane user id
session.is_new_user
session.has_share     # False = no wallet yet

# Phone (SMS) OTP, E.164 only
decane.connect_with_phone("+2348012345678")
session = decane.verify_phone_code("+2348012345678", "123456")

# Token exchanges, one step each
decane.connect_with_google_token(google_id_token)          # Google ID token you already hold
decane.connect_with_kingschat_token(kc_access_token)       # KingsChat OAuth access token
decane.connect_with_token(jwt_from_your_issuer, provider_id="prov-1")  # custom auth

# Sign-out
decane.revoke_access_token(session.access_token)
```

`get_user(session.access_token)` composes in one line when you also want
addresses. Google and KingsChat results carry a `profile`
(`AuthProfile(name, email, picture)`) passed through from the provider;
Decane never stores it.

Not available server-side (browser or authenticator required): the Google
redirect flow, the interactive KingsChat popup, and passkey sign-in. Signing
stays in the client SDKs.

### Sign-in caveats

- **Rate limits:** there is no per-IP limit on the auth endpoints, so a server
  funnelling every user through one egress IP is fine. OTP *attempts* are
  limited to 10 per 15 minutes per target email or phone, and code sends to 3
  per hour per address.
- **Tokens last 8 hours and can be renewed.** `POST /auth/refresh` with a
  still-valid token as the Bearer returns a fresh one, capped at 7 days from
  the sign-in. This package does not wrap it yet; call the endpoint directly
  if your server holds the token.
- `connect_with_email` always succeeds, whether or not the address is known
  (anti-enumeration by design). Never surface "email not found".

## Per-user records

Two small, flat JSON records live on every Decane user, split by **who may
write them**:

| Record        | Written with                           | Read with | For                                                  |
| ------------- | -------------------------------------- | --------- | ---------------------------------------------------- |
| `preferences` | the user's access token, or the secret | either    | the user's own choices (a settings screen)           |
| `metadata`    | the project secret **only**            | either    | your facts about the user: plan, flags, your own ids |

A user cannot change `metadata`, so an entitlement may live there. Values are
strings, numbers or booleans under short keys; at most 100 keys and 32 KB.

```python
# With the user's token, from a route that already verified it:
prefs = decane.get_preferences(token)                          # {"shine_perps": False, …}
decane.update_preferences(token, {"shine_perps": True, "old": None})  # merge; None removes
decane.set_preferences(token, {"theme": "dark"})               # replace
decane.get_metadata(token)                                     # read-only on this side

# By user id, with the project secret (dck_sk_…, from the project's page in
# the dashboard): pass `project_secret` or set DECANE_PROJECT_SECRET:
decane = DecaneClient(app_id=app_id, project_secret=os.environ["DECANE_PROJECT_SECRET"])
decane.update_user_metadata(claims.user_id, {"plan": "pro"})
decane.get_user_metadata(claims.user_id)
decane.set_user_metadata(claims.user_id, {})
decane.get_user_preferences(claims.user_id)                    # and update / set
```

A write that breaks the rules raises `DecaneApiError` with code
`INVALID_RECORD` (the message names the key); a user of another project is
`NOT_FOUND`.

## Server-side wallet creation

Create a user, and their wallet, before they have signed in. Decane keys the
user exactly as the matching sign-in will, so their first sign-in opens the
wallet made here and the client creates nothing. The key is generated in the
enclave; your server sees only the addresses.

```python
from decane import UserIdentifier

decane = DecaneClient(app_id=app_id, project_secret=os.environ["DECANE_PROJECT_SECRET"])

result = decane.create_user(UserIdentifier("email", "ada@example.com"))
result.user.addresses.evm  # fund it now; Ada finds it when she signs in
result.wallet              # "created" | "exists" | "none"

decane.create_user(UserIdentifier("phone", "+2348012345678"), metadata={"plan": "pro"})
decane.create_user(UserIdentifier("custom", "auth0|123", provider_id=provider_id), create_wallet=False)
decane.create_user_wallet(result.user.id)  # for an existing user with no wallet
```

Idempotent on the identifier. Off until the Decane team switches it on for
your project (`SERVER_WALLETS_DISABLED`); sixty calls a minute per project.
Errors: `INVALID_IDENTIFIER`, `WALLET_EXISTS`, `WALLET_FROZEN`, `RETRY_LATER`,
`ENCLAVE_UNAVAILABLE`.

## Token claims

Decane access tokens (ES256) carry `uid`, `sub`, `project_id`, `jti`, `iat`,
`exp`. `verify_access_token` checks the signature and expiry, and, when
`app_id` is set, that `project_id` matches (the audience-equivalent; Decane
tokens set no `iss`/`aud`). It returns `Claims` (`user_id`, `subject`,
`project_id`, `token_id`, `issued_at`, `expires_at`, `raw`).

## Errors

Three failure domains, three classes, all subclasses of `DecaneError`:

- **Verification** failures raise `DecaneAuthError` with a `reason` of
  `"invalid_token"`, `"missing_uid"` or `"project_mismatch"`. Use
  `safe_verify_access_token` for a `None`-returning variant.
- **Configuration** mistakes raise `DecaneConfigError` before any network
  call: a sign-in call without an `api_key`, a by-user-id call without a
  `project_secret`, a `verification_key` that is not a P-256 public key. An
  empty required argument (a blank email, token or user id) is a `ValueError`.
- **Sign-in / API** failures raise `DecaneApiError` with the backend's `code`
  and the HTTP `status`:

| Code | Meaning |
| --- | --- |
| `INVALID_CODE` | Email or SMS OTP wrong or expired |
| `AUTH_FAILED` | Provider token rejected |
| `INVALID_TOKEN` | Custom-auth JWT rejected |
| `NO_PROVIDER` / `AMBIGUOUS_PROVIDER` / `MISSING_CLAIM` | Custom-auth provider config issues |
| `INVALID_API_KEY` / `APP_ID_MISMATCH` | Credential problems |
| `SMS_NOT_CONFIGURED` | Phone sign-in on a deployment with no SMS provider |
| `KINGSCHAT_NOT_CONFIGURED` / `KINGSCHAT_UNAVAILABLE` | KingsChat setup / upstream outage |
| `INVALID_RECORD` / `NOT_FOUND` / `UNAUTHORIZED` | Record methods |
| `SERVER_WALLETS_DISABLED` / `INVALID_IDENTIFIER` / `WALLET_EXISTS` / `WALLET_FROZEN` / `RETRY_LATER` / `ENCLAVE_UNAVAILABLE` | User and wallet creation |
| `RATE_LIMITED` | HTTP 429 |
| `UNKNOWN` / `NETWORK_ERROR` | Unparseable response / request never completed (`status` 0) |

## Notes

- **Stateless.** Verification checks signature + expiry + project, not
  Decane's server-side revocation list. For immediate sign-out enforcement,
  additionally gate sensitive routes on a fresh backend call.
  (`revoke_access_token` does add the token to the revocation list, which
  backend calls like `get_addresses` honour immediately.)
- **A JWKS that cannot be fetched is not a bad token.** It raises
  `DecaneApiError` with code `NETWORK_ERROR`, and `safe_verify_access_token`
  lets it propagate rather than answering `None`, so an outage never reads as
  "unauthorized".
- **Migrating from Privy?** Decane's `uid` is a UUID, not a `did:privy:…`.
  Map legacy ids during a re-auth window before cutting production over.

## Config

| Option             | Env fallback              | Default                       | Purpose                                                         |
| ------------------ | ------------------------- | ----------------------------- | --------------------------------------------------------------- |
| `app_id`           |                           |                               | Enforce `project_id` matches; sent as `X-App-Id`.               |
| `verification_key` | `DECANE_VERIFICATION_KEY` | (falls back to JWKS)          | ES256 SPKI PEM; verify offline, no JWKS fetch.                  |
| `api_key`          | `DECANE_API_KEY`          |                               | `dck_live_…` key; required only for sign-in calls.              |
| `project_secret`   | `DECANE_PROJECT_SECRET`   |                               | `dck_sk_…` key; required only for by-user-id calls and creation. |
| `api_base`         | `DECANE_API_BASE`         | `https://backend.decane.app`  | Backend base URL; empty counts as unset.                        |
| `jwks_url`         |                           | `…/.well-known/jwks.json`     | Override the JWKS location (ignored with a key).                |
| `http_client`      |                           | an `httpx` client, 15 s timeout | Inject an `httpx.Client` / `httpx.AsyncClient`; used for the JWKS fetch too. |
| `timeout`          |                           | `15.0`                        | Seconds per request for the client-owned `httpx` client.        |

## Changelog

- **1.0.0**: mirrors `decane-node` 1.5.0: verification (static key and
  JWKS), server-side sign-in (email, phone, Google token, KingsChat token,
  custom auth), per-user records, and server-side user and wallet creation.
  Deviations from `decane-node`:
  - A JWKS fetch failure is `DecaneApiError` with code `NETWORK_ERROR`
    (status 0), not an auth error, and `safe_verify_access_token` propagates
    it (node returns `null`).
  - snake_case names throughout; the wire format is unchanged.
  - Sync (`DecaneClient`) and async (`AsyncDecaneClient`) clients.
  - The injected HTTP client (`http_client`) is used for the JWKS fetch too.
  - Default request timeout of 15 s.
  - A malformed `verification_key` fails at construction.
  - Config mistakes are `DecaneConfigError`; blank arguments are `ValueError`.
  - Expired tokens keep node's `invalid_token` reason and message.
  - Results are frozen dataclasses (`Claims`, `AuthResult`, `User`,
    `CreatedUser`, …); a `UserIdentifier` dataclass replaces the identifier
    object; `create_user` takes `create_wallet` and `metadata` as keyword
    arguments; `get_kingschat_config` returns a `KingsChatConfig`.

## License

MIT
