Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,48 @@ payroll.employees
payroll.timesheets
```

#### 3. Token File (self-refreshing)

This is the best choice for a self-hosted, single-user setup (e.g. running locally in Claude Desktop/Code) when you want **persistent authentication without a paid Custom Connection**, and it works in **every region** (Custom Connections do not).

Point the server at a token file with `XERO_TOKEN_FILE`. The server renews the access token via the rolling refresh token whenever it is near expiry, and persists the rotated refresh token back to the file (written atomically, `0600`).

**Generate the token file with the built-in `auth` command** — it runs the full OAuth2 **authorization-code flow** for you (opens your browser, captures the redirect on a local callback server, exchanges the code, and writes the file). No manual token wrangling:

```bash
XERO_CLIENT_ID=... XERO_CLIENT_SECRET=... \
XERO_TOKEN_FILE=/absolute/path/to/xero-tokens.json \
XERO_SCOPES="accounting.transactions accounting.contacts accounting.settings" \
npx -y @xeroapi/xero-mcp-server@latest auth
```

`XERO_SCOPES` is **required** for the `auth` command (space-separated; same var the server honours). `offline_access` is appended automatically — it is what yields the refresh token. The flow prints the connected tenants and their `tenantId` so you can set `XERO_TENANT_ID` for a multi-org token.

**One-time Xero-side prerequisite:** in your app at [developer.xero.com](https://developer.xero.com/), the app must be a **Web app** (has a client secret) and must list `http://localhost:53682/callback` under its allowed **Redirect URIs**. Without it the flow fails with `unauthorized_client` / a redirect mismatch.

Override the callback with `XERO_REDIRECT_URI` if `53682` is taken (register the exact same value in the Xero app). The default deliberately avoids port `5000`, which the macOS AirPlay Receiver occupies (`EADDRINUSE`).

```json
{
"mcpServers": {
"xero": {
"command": "npx",
"args": ["-y", "@xeroapi/xero-mcp-server@latest"],
"env": {
"XERO_TOKEN_FILE": "/absolute/path/to/xero-tokens.json",
"XERO_CLIENT_ID": "your_client_id_here",
"XERO_CLIENT_SECRET": "your_client_secret_here",
"XERO_TENANT_ID": "optional_tenant_id_to_pin_one_org"
}
}
}
}
```

The token file must contain at least `access_token` and `refresh_token` (the `auth` command writes exactly this, plus `_obtained_at`/`expires_at` bookkeeping). `XERO_CLIENT_ID` / `XERO_CLIENT_SECRET` are used to perform the refresh. `XERO_TENANT_ID` is optional — set it to pin the client to a single organisation when the token has multiple tenants connected; otherwise the first connected tenant is used.

NOTE: `XERO_TOKEN_FILE` takes precedence over `XERO_CLIENT_BEARER_TOKEN` and Custom Connections when defined. Request the same scopes listed under [Required Scopes for Bearer Token](#required-scopes-for-bearer-token) (plus `offline_access`).


### Available MCP Commands

Expand Down
208 changes: 208 additions & 0 deletions src/auth/bootstrap-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import axios from "axios";
import { spawn } from "child_process";
import crypto from "crypto";
import http from "http";
import { URL } from "url";

import {
DEFAULT_REDIRECT_URI,
XERO_AUTHORIZE_URL,
XERO_CONNECTIONS_URL,
XERO_TOKEN_URL,
resolveScopes,
} from "../consts/auth.js";
import { persistTokens, stampExpiry, TokenStore } from "./token-store.js";

const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes to complete login

// Everything the bootstrap is allowed to print goes to stderr — stdout is
// reserved for the MCP stdio transport on normal runs.
function log(msg: string): void {
process.stderr.write(`${msg}\n`);
}

// Open the system browser to the authorize URL. Single clear path per platform;
// on failure we fall through to printing the URL for manual paste.
//
// Alternative open methods for future reference:
// - npm pkg `open` (adds a dependency)
// - print-only: skip spawn entirely and always log the URL
function openBrowser(url: string): void {
const cmd =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
try {
const child = spawn(cmd, [url], {
stdio: "ignore",
detached: true,
shell: process.platform === "win32",
});
child.on("error", () => log(`Could not open browser. Open this URL manually:\n${url}`));
child.unref();
} catch {
log(`Could not open browser. Open this URL manually:\n${url}`);
}
}

/**
* Wait for Xero to redirect back to the loopback callback with `?code=...`.
* Resolves with the authorization code once a request matching the redirect
* path arrives and the `state` matches; rejects on state mismatch, an OAuth
* error param, or timeout. The server is always closed before settling.
*/
function waitForCallback(
redirectUri: string,
expectedState: string,
): Promise<string> {
const { port, pathname } = new URL(redirectUri);

return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
const reqUrl = new URL(req.url ?? "/", redirectUri);
if (reqUrl.pathname !== pathname) {
res.writeHead(404).end();
return;
}

const params = reqUrl.searchParams;
const error = params.get("error");
const code = params.get("code");
const state = params.get("state");

const finish = (status: number, body: string) => {
res.writeHead(status, { "Content-Type": "text/html" });
res.end(`<html><body><h2>${body}</h2>You can close this tab.</body></html>`);
server.close();
clearTimeout(timer);
};

if (error) {
finish(400, `Authorization failed: ${error}`);
reject(new Error(`Authorization failed: ${error}`));
} else if (state !== expectedState) {
finish(400, "State mismatch — aborting.");
reject(new Error("State mismatch on OAuth callback (possible CSRF)"));
} else if (!code) {
finish(400, "No authorization code returned.");
reject(new Error("No authorization code in callback"));
} else {
finish(200, "Authentication complete.");
resolve(code);
}
});

const timer = setTimeout(() => {
server.close();
reject(new Error("Timed out waiting for the OAuth callback"));
}, CALLBACK_TIMEOUT_MS);

server.on("error", reject);
server.listen(Number(port), () =>
log(`Listening for the Xero callback on ${redirectUri} ...`),
);
});
}

/** Exchange the authorization code for a token set (HTTP Basic client auth). */
async function exchangeCode(
code: string,
redirectUri: string,
clientId: string,
clientSecret: string,
): Promise<TokenStore> {
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(
"base64",
);
const body =
`grant_type=authorization_code` +
`&code=${encodeURIComponent(code)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}`;

const response = await axios.post(XERO_TOKEN_URL, body, {
headers: {
Authorization: `Basic ${credentials}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
});
return response.data as TokenStore;
}

/** Print the connected tenants so a multi-org user can set XERO_TENANT_ID. */
async function printConnections(accessToken: string): Promise<void> {
try {
const response = await axios.get(XERO_CONNECTIONS_URL, {
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
});
const connections = response.data as Array<{
tenantId: string;
tenantName?: string;
tenantType?: string;
}>;
if (!connections.length) {
log("No tenants connected yet.");
return;
}
log(`\nConnected tenant(s) — set XERO_TENANT_ID to pin one:`);
for (const c of connections) {
log(` ${c.tenantName ?? "(unnamed)"} [${c.tenantType ?? "?"}] -> ${c.tenantId}`);
}
} catch {
// Non-fatal: the token is already saved; tenant listing is a convenience.
log("Could not list connections (token saved regardless).");
}
}

/**
* Run the full OAuth2 authorization-code flow end to end: open the browser,
* catch the redirect on a one-shot local server, exchange the code, and write
* the token store that RefreshingTokenXeroClient consumes. No manual steps.
*
* Required env: XERO_CLIENT_ID, XERO_CLIENT_SECRET, XERO_TOKEN_FILE, XERO_SCOPES.
* Optional: XERO_REDIRECT_URI (defaults to the loopback callback).
*/
export async function runAuthorizationCodeFlow(): Promise<void> {
const clientId = process.env.XERO_CLIENT_ID;
const clientSecret = process.env.XERO_CLIENT_SECRET;
const tokenFile = process.env.XERO_TOKEN_FILE;
const redirectUri = process.env.XERO_REDIRECT_URI || DEFAULT_REDIRECT_URI;

const missing = [
!clientId && "XERO_CLIENT_ID",
!clientSecret && "XERO_CLIENT_SECRET",
!tokenFile && "XERO_TOKEN_FILE",
].filter(Boolean);
if (missing.length) {
throw new Error(`Missing required env var(s): ${missing.join(", ")}`);
}

const scope = resolveScopes(); // throws if XERO_SCOPES unset
const state = crypto.randomBytes(16).toString("hex");

const authorizeUrl = new URL(XERO_AUTHORIZE_URL);
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("client_id", clientId!);
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
authorizeUrl.searchParams.set("scope", scope);
authorizeUrl.searchParams.set("state", state);

// Start listening before opening the browser so the redirect can't race us.
const codePromise = waitForCallback(redirectUri, state);
log("Opening your browser to authorize with Xero ...");
openBrowser(authorizeUrl.toString());

const code = await codePromise;
log("Authorization code received. Exchanging for tokens ...");

const tok = stampExpiry(
await exchangeCode(code, redirectUri, clientId!, clientSecret!),
);
persistTokens(tokenFile!, tok);
log(`Tokens written to ${tokenFile} (mode 0600).`);

await printConnections(tok.access_token);
log("\nDone. Start the server normally with the same env to use it.");
}
50 changes: 50 additions & 0 deletions src/auth/token-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import fs from "fs";

/**
* Shape of the OAuth2 token store on disk. It is the raw token response from
* Xero's identity endpoint with two added bookkeeping fields:
* - `_obtained_at`: epoch seconds when the token was minted/refreshed.
* - `expires_at`: epoch seconds when the access token expires.
*
* Both the bootstrap flow (authorization-code) and the running server's
* RefreshingTokenXeroClient (refresh-token) read and write this exact shape, so
* the two cannot drift.
*/
export interface TokenStore {
access_token: string;
refresh_token: string;
token_type?: string;
expires_in?: number;
scope?: string;
_obtained_at?: number;
expires_at?: number;
// Tolerate any extra fields Xero returns without losing them on re-persist.
[key: string]: unknown;
}

/** Read and parse the token store. Throws if the file is missing or malformed. */
export function readTokens(path: string): TokenStore {
return JSON.parse(fs.readFileSync(path, "utf-8")) as TokenStore;
}

/**
* Write the token store atomically with 0600 perms: write a sibling `.tmp` file
* then rename over the target, so a crash mid-write can never leave a truncated
* token file (and the refresh token inside is never world-readable).
*/
export function persistTokens(path: string, tok: TokenStore): void {
const tmp = `${path}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(tok, null, 2), { mode: 0o600 });
fs.renameSync(tmp, path);
}

/**
* Stamp `_obtained_at`/`expires_at` onto a fresh token response (mutates and
* returns it). `expires_in` defaults to 1800s (30 min) when Xero omits it.
*/
export function stampExpiry(tok: TokenStore): TokenStore {
const now = Math.floor(Date.now() / 1000);
tok._obtained_at = now;
tok.expires_at = now + (tok.expires_in ?? 1800);
return tok;
}
Loading