Authentication.
Browser PKCE, device flow, token persistence, and API keys for the client SDK.
The SDK provides a managed auth tier: on successful login, a TokenManager is auto-configured on the client instance. Every subsequent request automatically carries a managed access token with single-flight, skew-aware auto-refresh.
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.loginWithBrowser(opts)
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.
const tokens = await client.auth.loginWithBrowser({
clientId: 'matilda-code',
openBrowser: (url) => console.log(`Open: ${url}`),
});
console.log(tokens.accessToken);BrowserLoginOptions
| Field | type | description |
|---|---|---|
| clientId | string | OAuth client alias (e.g. 'matilda-code'). |
| scope | string | Space-separated OAuth scopes. Defaults to 'openid offline_access'. |
| identityProviderId | string | Route straight to a federated IdP (e.g. Google SSO) instead of the hosted login page. |
| callbackPort | number | Fixed loopback port. Recommended for FusionAuth redirect validation. Defaults to a random ephemeral port. |
| timeoutMs | number | How long to wait for the browser callback. Defaults to 300_000 ms (5 min). |
| openBrowser | (url: string) => void | Promise<void> | Called with the authorize URL. If omitted, caller handles browser opening. |
| successRedirect | string | URL the browser is 302-redirected to on success. Defaults to 'https://matilda.maincode.com/cli/signed-in'. |
| errorRedirect | string | URL for the error case; otherwise a bare 400 text response. |
| fetchImpl | typeof fetch | Override fetch (testing, custom transport). Defaults to global fetch. |
| tokenStore | StorageAdapter | Custom token persistence. Defaults to memoryStorage(). |
| tokenLock | <T>(fn: () => Promise<T>) => Promise<T> | Cross-process critical-section lock for token refresh (e.g. from createFileTokenStore). |
| onEvent | (e: LoginFlowEvent) => void | Subscribe to login flow state events. |
Returns
Promise<TokenSet> — the token set from the login flow. The TokenManager is also auto-configured on the client instance.
auth.loginWithDeviceFlow(opts)
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.
const tokens = await client.auth.loginWithDeviceFlow({
clientId: 'matilda-code',
onEvent: (e) => {
if (e.type === 'user_code') {
console.log(`Visit ${e.verificationUri} and enter code: ${e.userCode}`);
}
},
});If no onEvent handler is provided, the SDK prints the user code and verification URL to stderr automatically.
DeviceLoginOptions
| Field | type | description |
|---|---|---|
| clientId | string | OAuth client alias. |
| scope | string | Space-separated OAuth scopes. Defaults to 'openid offline_access'. |
| timeoutMs | number | Polling timeout. Defaults to 300_000 ms (5 min). |
| signal | AbortSignal | Abort the polling loop. |
| fetchImpl | typeof fetch | Override fetch. Defaults to global fetch. |
| tokenStore | StorageAdapter | Custom token persistence. Defaults to memoryStorage(). |
| tokenLock | <T>(fn: () => Promise<T>) => Promise<T> | Cross-process lock for token refresh. |
| onEvent | (e: LoginFlowEvent) => void | Subscribe to login flow events. If omitted, prints user code to stderr. Defaults to defaultDeviceOnEvent. |
Returns
Promise<TokenSet>
auth.refreshToken(refreshToken, clientId)
Manually refresh an access token using a refresh token. This bypasses the TokenManager — use it only when you need raw token exchange.
const tokens = await client.auth.refreshToken(oldRefreshToken, 'matilda-code');| Field | type | description |
|---|---|---|
| refreshToken | string | The refresh token to exchange. |
| clientId | string | OAuth client alias. |
Returns Promise<TokenSet>.
auth.getTokens()
Returns the current token set from the managed TokenManager, or null if not authenticated.
const tokens = await client.auth.getTokens();
if (tokens) {
console.log(`Token expires at: ${new Date(tokens.expiresAt).toISOString()}`);
}Returns Promise<TokenSet | null>.
auth.logout()
Clears the token store, destroys the TokenManager, and disconnects the client's getToken provider.
await client.auth.logout();Returns Promise<void>.
Token persistence
By default, tokens are stored in memory (memoryStorage()). For cross-process persistence (e.g. CLI sessions), use createFileTokenStore from the /auth/node subpath:
import Matilda from '@maincode-ai/matilda-client-sdk';
import { createFileTokenStore } from '@maincode-ai/matilda-client-sdk/auth/node';
import { homedir } from 'node:os';
import { join } from 'node:path';
const tokenPath = join(homedir(), '.matilda', 'tokens.json');
const { store, lock } = createFileTokenStore(tokenPath);
const client = new Matilda({ baseUrl: 'https://matilda.maincode.com/api' });
await client.auth.loginWithBrowser({
clientId: 'matilda-code',
tokenStore: store,
tokenLock: lock,
});The file store uses a 0600 JSON file with a lockfile-based single-writer lock to prevent cross-process refresh races.
StorageAdapter interface
interface StorageAdapter {
get(key: string): string | null | Promise<string | null>;
set(key: string, value: string): void | Promise<void>;
remove(key: string): void | Promise<void>;
}Implement this to store tokens in a database, keychain, or any custom backend.
TokenManager interface
interface TokenManager {
getAccessToken(opts?: { forceRefresh?: boolean }): Promise<string>;
getTokens(): Promise<TokenSet | null>;
setTokens(tokens: TokenSet): Promise<void>;
clear(): Promise<void>;
}Created via createTokenManager(deps) from /auth/node. The SDK auto-creates one on login.
TokenSet interface
interface TokenSet {
accessToken: string;
refreshToken?: string;
idToken?: string; // OIDC id_token when 'openid' scope is granted
expiresAt: number; // epoch milliseconds
}AuthError class
class AuthError extends Error {
readonly code: string; // OAuth error code (e.g. 'invalid_grant', 'authorization_pending')
readonly retryable: boolean; // true for transient 5xx/network; false for revoked tokens
}LoginFlowEvent type
type LoginFlowEvent =
| { type: 'state'; status: 'idle' | 'awaiting_user' | 'exchanging' | 'authenticated' | 'error' }
| { type: 'authorize_url'; url: string }
| { type: 'user_code'; userCode: string; verificationUri: string; verificationUriComplete?: string };Standalone auth subpath (/auth)
The /auth subpath provides the raw PKCE and device-flow helpers without the managed TokenManager. These are deprecated in favour of the managed client.auth.* methods, but remain available for integrators who need direct protocol access:
| Export | Description |
|---|---|
loginWithBrowser(coreAuthUrl, opts) | Raw PKCE browser login. Returns TokenSet. |
loginWithDeviceFlow(coreAuthUrl, opts) | Raw device flow. Returns TokenSet. |
refreshToken(coreAuthUrl, clientId, refreshToken) | Raw token refresh. Returns TokenSet. |
createPkcePair() | Generate PKCE code_verifier + code_challenge (S256). |
buildAuthorizeUrl(coreAuthUrl, opts) | Construct the authorize URL. |
The /auth/node subpath adds the Node-only adapters on top of the isomorphic core:
| Export | Description |
|---|---|
createLoginFlow(opts) | Headless login controller for loopback + device transports. |
createTokenManager(deps) | Per-session token manager with single-flight refresh. |
createFileTokenStore(filePath) | 0600 JSON file store with cross-process lock. |
createLoopbackReceiver(opts) | RFC 8252 loopback redirect receiver. |
fetchAuthServerMetadata(issuer, fetchImpl?, opts?) | RFC 8414 metadata discovery. |
memoryStorage() | In-memory StorageAdapter. |
beginLogin(authorizationEndpoint, params) | Stateless "begin" half of a redirect/BFF login. |
completeLogin(tokenEndpoint, params, fetchImpl?) | Stateless "complete" half. |
API Key Management
The SDK provides convenience methods for managing mc_live_ API keys. These hit the same JwtGuard-protected endpoints the developer dashboard uses — the session JWT from a prior loginWithBrowser() or loginWithDeviceFlow() call is carried automatically by the TokenManager.
auth.createApiKey(opts)
Mints a new API key. The secret is returned only at creation time — store it immediately.
await client.auth.loginWithDeviceFlow({ clientId: 'matilda-code' });
const key = await client.auth.createApiKey({
name: 'ci-runner',
// scopes: ['api:code'], // omit → server default
// expiresAt: '2026-12-31T23:59:59Z', // omit → never expires
});
console.log(key.secret); // mc_live_... — shown only once
console.log(key.keyPrefix); // mc_live_abcd
console.log(key.id); // UUID for revocationCreateApiKeyOptions
| Field | type | description |
|---|---|---|
| name | string | Human-readable key name (1–80 chars). |
| scopes | string[] | Permission scopes (e.g. ['api:code']). Defaults to the server default. |
| expiresAt | string | ISO 8601 expiry timestamp. Omit for no expiry. |
Returns Promise<ApiKeyWithSecret>.
auth.listApiKeys()
Lists all non-revoked API keys for the authenticated user. Secrets are never included — only the keyPrefix for identification.
const keys = await client.auth.listApiKeys();
for (const key of keys) {
console.log(`${key.keyPrefix} ${key.name} ${key.revokedAt ? 'REVOKED' : 'ACTIVE'}`);
}Returns Promise<ApiKey[]>.
auth.revokeApiKey(id)
Revokes a key by ID. The key immediately stops working for authentication.
await client.auth.revokeApiKey(key.id);| Field | type | description |
|---|---|---|
| id | string | The key UUID (from createApiKey or listApiKeys). |
Returns Promise<ApiKey> (the revoked key with revokedAt set).
ApiKey interface
interface ApiKey {
id: string;
name: string;
keyPrefix: string; // e.g. 'mc_live_abcd1234'
scopes: string[];
createdAt: string; // ISO 8601
lastUsedAt: string | null;
revokedAt: string | null;
expiresAt: string | null;
}ApiKeyWithSecret interface
Extends ApiKey with the one-time secret:
interface ApiKeyWithSecret extends ApiKey {
secret: string; // full key, e.g. 'mc_live_...' — shown only at creation
}CLI — matilda-key
The package ships a matilda-key CLI binary 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 binary is available via npx (no global install required) or after installing the package:
npx matilda-key create-api-key --name "my-key"
# or, if installed globally:
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
[state] awaiting_user_verification
[state] token_received
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.