Authentication.
Browser PKCE, device flow, token persistence, and API keys for the Python client SDK.
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.
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.
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.
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.
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.
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:
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
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
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() # -> NoneAlso 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
@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 grantedTokenSet.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
class AuthError(MatildaError):
code: str # OAuth error code (e.g. 'invalid_grant', 'authorization_pending')
retryable: bool # True for transient 5xx/network; False for revoked tokensLoginFlowEvent
Three frozen dataclasses, each with a type discriminator:
# 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_completeStandalone 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.
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 revocationParameters
| 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.
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.
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:
# 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:
matilda-key create-api-key --name "my-key"
# or, without installing:
uvx --from matilda-client matilda-key create-api-key --name "my-key"Usage
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
- Device-flow login — a verification URL and code are printed to stderr. Open the URL, sign in with Google or Apple, enter the code.
- Key minting — once authenticated, an API key is created from the session JWT.
- 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
# 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
$ 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.