# Authentication

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

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

---

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`.

```ts title="login.ts"
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.

```ts title="device.ts"
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.

```ts
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.

```ts
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.

```ts
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:

```ts title="persist.ts"
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

```ts
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

```ts
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

```ts
interface TokenSet {
  accessToken: string;
  refreshToken?: string;
  idToken?: string;       // OIDC id_token when 'openid' scope is granted
  expiresAt: number;      // epoch milliseconds
}
```

## `AuthError` class

```ts
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

```ts
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.

```ts title="api-key.ts"
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 revocation
```

#### `CreateApiKeyOptions`

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

```ts
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.

```ts
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

```ts
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:

```ts
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:

```bash
npx matilda-key create-api-key --name "my-key"
# or, if installed globally:
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

  [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.
