# Authentication

> Browser PKCE, device flow, token persistence, and API keys for the Python client SDK.

Source: https://maincode.com/docs/python-client-sdk-authentication
Section: Client SDK · Matilda documentation

---

The SDK provides a managed auth tier: on successful login, a `TokenManager` is auto-configured as the client's token provider. Every subsequent request automatically carries a managed access token with single-flight, skew-aware auto-refresh — and a 401 response triggers one `force_refresh` + retry before the failure surfaces.

## OAuth client IDs

The following OAuth client ID is currently available for public integrations:

| Client ID | Use case | Notes |
| - | - | - |
| `matilda-code` | Public CLI / SDK / agent integrations | PKCE + device flow. The default choice for most integrations. |

`matilda-code` is the only publicly accessible client ID at the moment. Additional client IDs will be documented here as access opens up.

## `auth.login_with_browser(...)`

Managed PKCE browser login (RFC 8252 loopback). Starts a temporary local server, opens the browser, receives the auth code, exchanges it for tokens, and auto-wires a `TokenManager`.

```python title="login.py"
async def main():
    async with MatildaClient() as client:
        tokens = await client.auth.login_with_browser(
            client_id="matilda-code",
            open_browser=lambda url: print(f"Open: {url}"),
        )
        print(tokens.access_token)
```

### Parameters

| Field | Type | Description |
| - | - | - |
| `client_id` | `str` | OAuth client alias (e.g. 'matilda-code'). |
| `scope` | `str` | Space-separated OAuth scopes. Defaults to 'openid offline\_access'. |
| `identity_provider_id` | `str \| None` | Route straight to a federated IdP (e.g. Google SSO) instead of the hosted login page. |
| `callback_port` | `int \| None` | Fixed loopback port. Recommended for FusionAuth redirect validation. Defaults to a random ephemeral port. |
| `timeout_ms` | `int` | How long to wait for the browser callback. Defaults to 300\_000 ms (5 min). |
| `open_browser` | `Callable[[str], None \| Awaitable[None]] \| None` | Called with the authorize URL. If omitted, caller handles browser opening. |
| `success_redirect` | `str` | URL the browser is 302-redirected to on success. Defaults to https\://matilda.maincode.com/cli/signed-in. |
| `error_redirect` | `str \| None` | URL for the error case; otherwise a bare 400 text response. |
| `token_store` | `StorageAdapter \| None` | Custom token persistence. Defaults to in-memory storage. |
| `token_lock` | `TokenLock \| None` | Cross-process critical-section lock for token refresh (e.g. from create\_file\_token\_store). |
| `on_event` | `Callable[[LoginFlowEvent], None] \| None` | Subscribe to login flow state events. |
| `http` | `httpx.AsyncClient \| None` | Override HTTP transport (testing, custom clients). |

### Returns

`TokenSet` — the token set from the login flow. The `TokenManager` is also auto-configured on the client instance.

## `auth.login_with_device_flow(...)`

Managed RFC 8628 device flow. Requests a device code, prints the user code and verification URL to stderr (by default), and polls until the user authorises.

```python title="device.py"
async def main():
    async with MatildaClient() as client:
        tokens = await client.auth.login_with_device_flow(
            client_id="matilda-code",
            on_event=lambda e: (
                print(f"Visit {e.verification_uri} and enter code: {e.user_code}")
                if e.type == "user_code"
                else None
            ),
        )
```

If no `on_event` handler is provided, the SDK prints the user code and verification URL to stderr automatically (`default_device_on_event`).

### Parameters

| Field | Type | Description |
| - | - | - |
| `client_id` | `str` | OAuth client alias. |
| `scope` | `str` | Space-separated OAuth scopes. Defaults to 'openid offline\_access'. |
| `token_store` | `StorageAdapter \| None` | Custom token persistence. Defaults to in-memory storage. |
| `token_lock` | `TokenLock \| None` | Cross-process lock for token refresh. |
| `on_event` | `Callable[[LoginFlowEvent], None] \| None` | Subscribe to login flow events. If omitted, prints user code to stderr. Defaults to default\_device\_on\_event. |
| `http` | `httpx.AsyncClient \| None` | Override HTTP transport. |

### Returns

`TokenSet`

## `auth.refresh_token(refresh_token, client_id)`

Manually refresh an access token using a refresh token. This bypasses the `TokenManager` — use it only when you need raw token exchange.

```python
tokens = await client.auth.refresh_token(old_refresh_token, "matilda-code")
```

| Field | Type | Description |
| - | - | - |
| `refresh_token` | `str` | The refresh token to exchange. |
| `client_id` | `str` | OAuth client alias. |

Returns `TokenSet`.

## `auth.get_tokens()`

Returns the current token set from the managed `TokenManager`, or `None` if not authenticated.

```python
tokens = await client.auth.get_tokens()
if tokens:
    print(f"Token expires at epoch ms: {tokens.expires_at}")
```

Returns `TokenSet | None`.

## `auth.logout()`

Clears the token store, destroys the `TokenManager`, and disconnects the client's token provider.

```python
await client.auth.logout()
```

Returns `None`.

## Token persistence

By default, tokens are stored in memory (`MemoryStorage()`). For cross-process persistence (e.g. CLI sessions), use `create_file_token_store`:

```python title="persist.py"
from pathlib import Path

from matilda_client import MatildaClient, create_file_token_store


async def main():
    token_path = Path.home() / ".matilda" / "tokens.json"
    token_store = create_file_token_store(token_path)

    async with MatildaClient() as client:
        await client.auth.login_with_browser(
            client_id="matilda-code",
            token_store=token_store.store,
            token_lock=token_store.lock,
        )
```

The file store uses a `0600` JSON file with a lockfile-based single-writer lock to prevent cross-process refresh races.

## `StorageAdapter` protocol

```python
class StorageAdapter(Protocol):
    def get(self, key: str) -> str | None | Awaitable[str | None]: ...
    def set(self, key: str, value: str) -> None | Awaitable[None]: ...
    def remove(self, key: str) -> None | Awaitable[None]: ...
```

Implement this (sync or async) to store tokens in a database, keychain, or any custom backend.

## `TokenManager`

```python
manager = TokenManager(
    token_endpoint="https://matilda.maincode.com/api/auth/oauth/token",
    client_id="matilda-code",
    store=...,
)

await manager.get_access_token(force_refresh=False)  # -> str
await manager.get_tokens()                           # -> TokenSet | None
await manager.set_tokens(tokens)                     # -> None
await manager.clear()                                # -> None
```

Also constructible via the `create_token_manager(**kwargs)` factory. The SDK auto-creates one on login. Refresh is single-flight per instance, evicts the session on `invalid_grant`, and re-reads the store inside the optional `TokenLock` so a process that lost a cross-process refresh race sees the winner's fresh token.

## `TokenSet`

```python
@dataclass(frozen=True)
class TokenSet:
    access_token: str
    expires_at: int              # epoch milliseconds
    refresh_token: str | None
    id_token: str | None         # OIDC id_token when 'openid' scope is granted
```

`TokenSet.to_json_dict()` / `TokenSet.from_json_dict(...)` serialise with camelCase keys — the on-disk JSON contract is shared with the TypeScript SDK, so token stores can be exchanged between the two.

## `AuthError` class

```python
class AuthError(MatildaError):
    code: str         # OAuth error code (e.g. 'invalid_grant', 'authorization_pending')
    retryable: bool   # True for transient 5xx/network; False for revoked tokens
```

## `LoginFlowEvent`

Three frozen dataclasses, each with a `type` discriminator:

```python
# StateEvent        (type="state")         — status: 'idle' | 'awaiting_user' | 'exchanging' | 'authenticated' | 'error'
# AuthorizeUrlEvent (type="authorize_url") — url: str
# UserCodeEvent     (type="user_code")     — user_code, verification_uri, verification_uri_complete
```

## Standalone auth helpers

The `matilda_client.auth` module provides the raw PKCE and device-flow protocol helpers without the managed `TokenManager`. The managed `client.auth.*` methods cover most integrations, but these remain available for integrators who need direct protocol access:

| Function | Description |
| - | - |
| `fetch_auth_server_metadata(issuer)` | RFC 8414 metadata discovery. |
| `begin_login(authorization_endpoint, ...)` | Stateless "begin" half of a redirect/BFF login. Returns `BeginLoginResult`. |
| `complete_login(token_endpoint, ...)` | Stateless "complete" half — validates `state`, exchanges the code. |
| `refresh_token_grant(token_endpoint, client_id, refresh_token)` | Raw token refresh. Returns `TokenSet`. |
| `request_device_code(device_authorization_endpoint, ...)` | Start a device flow. Returns `DeviceAuthorization`. |
| `poll_device_token(token_endpoint, ...)` | Poll until the user authorises or the code expires. |
| `create_pkce_pair()` | Generate PKCE `code_verifier` + `code_challenge` (S256). |
| `build_authorize_url(authorization_endpoint, ...)` | Construct the authorize URL. |
| `assert_state(received, expected)` | Validate the OAuth `state` round-trip (CSRF guard). |
| `random_state()` | High-entropy OAuth `state` value. |
| `base64_url_encode(data)` | Padding-free base64url (RFC 4648 §5). |

The `matilda_client.auth_local` module adds the local-machine adapters on top of the protocol core:

| Function / class | Description |
| - | - |
| `run_loopback_login_flow(...)` | Headless loopback login (PKCE + RFC 8252 redirect). |
| `run_device_login_flow(...)` | Headless device-flow login (RFC 8628). |
| `create_loopback_receiver(...)` | RFC 8252 single-use loopback redirect receiver. |
| `create_file_token_store(file_path)` | `0600` JSON file store with cross-process lock. |
| `default_device_on_event(event)` | Default device-flow event handler (prints code to stderr). |
| `memory_storage()` / `MemoryStorage` | In-memory `StorageAdapter`. |
| `create_token_manager(**kwargs)` | Per-session token manager with single-flight refresh. |

## API Key Management

The SDK provides methods for managing `mc_live_` API keys. These hit the same JWT-protected endpoints the developer dashboard uses — the session JWT from a prior `login_with_browser()` or `login_with_device_flow()` call is carried automatically by the `TokenManager`.

### `api_keys.create(...)`

Mints a new API key. The secret is returned **only at creation time** — store it immediately.

```python title="api_key.py"
async def main():
    async with MatildaClient() as client:
        await client.auth.login_with_device_flow(client_id="matilda-code")

        key = await client.api_keys.create(
            name="ci-runner",
            # scopes=["api:code"],                  # omit → server default
            # expires_at="2026-12-31T23:59:59Z",    # omit → never expires
        )

        print(key["secret"])      # mc_live_...  — shown only once
        print(key["keyPrefix"])   # mc_live_abcd
        print(key["id"])          # UUID for revocation
```

#### Parameters

| Field | Type | Description |
| - | - | - |
| `name` | `str` | Human-readable key name (1–80 chars). |
| `scopes` | `list[str] \| None` | Permission scopes (e.g. \['api:code']). Defaults to the server default. |
| `expires_at` | `str \| None` | ISO 8601 expiry timestamp. Omit for no expiry. |

Returns a `dict` — the key metadata plus the one-time `secret`.

### `api_keys.list()`

Lists all non-revoked API keys for the authenticated user. Secrets are never included — only the `keyPrefix` for identification.

```python
keys = await client.api_keys.list()
for key in keys:
    status = "REVOKED" if key.get("revokedAt") else "ACTIVE"
    print(f"{key['keyPrefix']}  {key['name']}  {status}")
```

Returns `list[dict]`.

### `api_keys.revoke(key_id)`

Revokes a key by ID. The key immediately stops working for authentication.

```python
await client.api_keys.revoke(key["id"])
```

| Field | Type | Description |
| - | - | - |
| `key_id` | `str` | The key UUID (from create or list). |

Returns a `dict` (the revoked key with `revokedAt` set).

### Response shape

API key dicts use the server's camelCase wire keys:

```python
# ApiKey dict:
{
    "id": "8f3a2b1c-...",
    "name": "ci-runner",
    "keyPrefix": "mc_live_abcd1234",
    "scopes": ["api:code"],
    "createdAt": "2026-08-18T10:30:00.000Z",
    "lastUsedAt": None,
    "revokedAt": None,
    "expiresAt": None,
    # "secret": "mc_live_..."  — create() only, shown exactly once
}
```

## CLI — `matilda-key`

The package ships a `matilda-key` CLI that mints API keys via device-flow login. This is the easiest way for social-login (Google/Apple) users to get an API key without writing code.

### Installation

The console script is installed with the package (on the venv's `PATH`), or run without installing via `uvx`:

```bash
matilda-key create-api-key --name "my-key"
# or, without installing:
uvx --from matilda-client matilda-key create-api-key --name "my-key"
```

### Usage

```bash
matilda-key create-api-key --name <key-name> [--scopes ...] [--expires-at ...] [--api-base-url ...] [--client-id ...]
```

| Field | Type | Description |
| - | - | - |
| `--name` | `string` | API key name (1–80 chars). |
| `--scopes` | `string` | Comma-separated scopes (e.g. api:code,api:chat). Defaults to the server default. |
| `--expires-at` | `string` | ISO 8601 expiry date. Defaults to never expiring. |
| `--api-base-url` | `string` | API base URL. Defaults to https\://matilda.maincode.com/api. |
| `--client-id` | `string` | OAuth client ID / alias. Defaults to matilda-code. |

The `MATILDA_API_BASE_URL` environment variable is also honoured as a fallback for `--api-base-url`.

### What happens when you run it

1. **Device-flow login** — a verification URL and code are printed to **stderr**. Open the URL, sign in with Google or Apple, enter the code.
2. **Key minting** — once authenticated, an API key is created from the session JWT.
3. **Output** — key metadata (ID, name, prefix, scopes, expiry) is printed to **stderr**. The secret is printed to **stdout**.

The stdout/stderr separation is deliberate: the secret on stdout is clean and pipeable, while the login flow and metadata remain visible on the terminal via stderr.

### Piping the secret

```bash
# Capture into an env var (login flow still visible on terminal):
MATILDA_API_KEY=$(matilda-key create-api-key --name "ci-runner")

# Pipe to a file:
matilda-key create-api-key --name "ci-runner" > /tmp/key.txt

# Use in CI:
export MATILDA_API_KEY="$(cat /tmp/key.txt)"
```

### Full example

```bash
$ matilda-key create-api-key --name "ci-runner" --scopes api:code

Starting device-flow login...

  Open https://matilda.maincode.com/device and enter code: ABCD-1234

Login successful!

Minting API key "ci-runner"...

API key created successfully.
  ID:          8f3a2b1c-...
  Name:        ci-runner
  Prefix:      mc_live_abcd1234
  Scopes:      api:code
  Expires:     never
  Created at:  2026-08-18T10:30:00.000Z

Secret printed to stdout. Store it securely — it won't be shown again.
```

The secret (`mc_live_...`) is on stdout; everything else is on stderr.
