diff --git a/README.md b/README.md index 40044369..85e2a174 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/auth/bootstrap-auth.ts b/src/auth/bootstrap-auth.ts new file mode 100644 index 00000000..af4cd996 --- /dev/null +++ b/src/auth/bootstrap-auth.ts @@ -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 { + 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(`

${body}

You can close this tab.`); + 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 { + 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 { + 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 { + 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."); +} diff --git a/src/auth/token-store.ts b/src/auth/token-store.ts new file mode 100644 index 00000000..9d4f04c0 --- /dev/null +++ b/src/auth/token-store.ts @@ -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; +} diff --git a/src/clients/xero-client.ts b/src/clients/xero-client.ts index 2a0097c3..814346ae 100644 --- a/src/clients/xero-client.ts +++ b/src/clients/xero-client.ts @@ -7,6 +7,12 @@ import { XeroClient, } from "xero-node"; +import { + persistTokens, + readTokens, + stampExpiry, + TokenStore, +} from "../auth/token-store.js"; import { ensureError } from "../helpers/ensure-error.js"; dotenv.config(); @@ -14,12 +20,23 @@ dotenv.config(); const client_id = process.env.XERO_CLIENT_ID; const client_secret = process.env.XERO_CLIENT_SECRET; const bearer_token = process.env.XERO_CLIENT_BEARER_TOKEN; +const token_file = process.env.XERO_TOKEN_FILE; const grant_type = "client_credentials"; -if (!bearer_token && (!client_id || !client_secret)) { +if (!token_file && !bearer_token && (!client_id || !client_secret)) { throw Error("Environment Variables not set - please check your .env file"); } +// XERO_TOKEN_FILE mode reads an OAuth2 token store (authorization-code flow with +// offline_access) and self-refreshes via the rolling refresh token. Free, works +// in every region, and never lapses mid-session — unlike static bearer tokens. +if (token_file && (!client_id || !client_secret)) { + throw Error( + "XERO_TOKEN_FILE mode requires XERO_CLIENT_ID and XERO_CLIENT_SECRET " + + "(set XERO_TENANT_ID to pin a single org when the token has several connected)", + ); +} + abstract class MCPXeroClient extends XeroClient { public tenantId: string; private shortCode: string; @@ -120,13 +137,15 @@ class CustomConnectionsXeroClient extends MCPXeroClient { const axiosError = error as AxiosError; const data = axiosError.response?.data; const message = - typeof data === "object" ? JSON.stringify(data) : data || axiosError.message; + typeof data === "object" + ? JSON.stringify(data) + : data || axiosError.message; return new Error(`Failed to get Xero token${context}: ${message}`); } public async getClientCredentialsToken(): Promise { // If XERO_SCOPES is set, use that - if (process.env.XERO_SCOPES) { + if (process.env.XERO_SCOPES) { try { return await this.requestToken(process.env.XERO_SCOPES); } catch (envError) { @@ -220,12 +239,104 @@ class BearerTokenXeroClient extends MCPXeroClient { } } -export const xeroClient = bearer_token - ? new BearerTokenXeroClient({ - bearerToken: bearer_token, - }) - : new CustomConnectionsXeroClient({ +/** + * Auth mode that reads an OAuth2 token store from disk (a JSON file containing + * at least `access_token` and `refresh_token`) and renews the access token via + * the rolling refresh token when it is near expiry. The rotated refresh token is + * persisted back atomically with 0600 permissions. + * + * This gives self-hosted/local users persistent, free authentication without a + * paid Custom Connection — and works in every region. Because authenticate() + * runs at the start of every handler, each tool call gets a valid token. + * + * Activated by XERO_TOKEN_FILE. Requires XERO_CLIENT_ID and XERO_CLIENT_SECRET + * to perform the refresh. If XERO_TENANT_ID is set, the client is pinned to that + * organisation (useful when the token has multiple tenants connected); otherwise + * it falls back to the first connected tenant. + */ +class RefreshingTokenXeroClient extends MCPXeroClient { + private readonly tokenFile: string; + private readonly clientId: string; + private readonly clientSecret: string; + private readonly tenantOverride?: string; + + constructor(config: { + tokenFile: string; + clientId: string; + clientSecret: string; + tenantId?: string; + }) { + super(); + this.tokenFile = config.tokenFile; + this.clientId = config.clientId; + this.clientSecret = config.clientSecret; + this.tenantOverride = config.tenantId || undefined; + } + + private async refresh(refreshToken: string): Promise { + const credentials = Buffer.from( + `${this.clientId}:${this.clientSecret}`, + ).toString("base64"); + + const response = await axios.post( + "https://identity.xero.com/connect/token", + `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`, + { + headers: { + Authorization: `Basic ${credentials}`, + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + }, + ); + + const tok = stampExpiry(response.data as TokenStore); + persistTokens(this.tokenFile, tok); + return tok; + } + + public async authenticate(): Promise { + let tok = readTokens(this.tokenFile); + const now = Math.floor(Date.now() / 1000); + + // Prefer an absolute expires_at; fall back to _obtained_at + expires_in; + // if neither is present, treat as expired and refresh. + const expiresAt = + tok.expires_at ?? (tok._obtained_at ?? 0) + (tok.expires_in ?? 1800); + + // refresh with a 5-minute safety buffer + if (now > expiresAt - 300) { + tok = await this.refresh(tok.refresh_token); + } + + this.setTokenSet({ + access_token: tok.access_token, + refresh_token: tok.refresh_token, + expires_in: tok.expires_in, + token_type: tok.token_type, + }); + + if (this.tenantOverride) { + this.tenantId = this.tenantOverride; + } else { + await this.updateTenants(); + } + } +} + +export const xeroClient = token_file + ? new RefreshingTokenXeroClient({ + tokenFile: token_file, clientId: client_id!, clientSecret: client_secret!, - grantType: grant_type, - }); + tenantId: process.env.XERO_TENANT_ID, + }) + : bearer_token + ? new BearerTokenXeroClient({ + bearerToken: bearer_token, + }) + : new CustomConnectionsXeroClient({ + clientId: client_id!, + clientSecret: client_secret!, + grantType: grant_type, + }); diff --git a/src/consts/auth.ts b/src/consts/auth.ts new file mode 100644 index 00000000..796f8556 --- /dev/null +++ b/src/consts/auth.ts @@ -0,0 +1,42 @@ +// OAuth2 authorization-code flow endpoints. The authorize URL lives on +// login.xero.com; token exchange and refresh share identity.xero.com. +export const XERO_AUTHORIZE_URL = + "https://login.xero.com/identity/connect/authorize"; +export const XERO_TOKEN_URL = "https://identity.xero.com/connect/token"; +export const XERO_CONNECTIONS_URL = "https://api.xero.com/connections"; + +// Loopback redirect the local callback server listens on. Override with +// XERO_REDIRECT_URI; this exact value must be registered in the Xero app's +// allowed Redirect URIs at developer.xero.com. Port 5000 is deliberately avoided +// — macOS Monterey+ runs the AirPlay Receiver there (EADDRINUSE). +export const DEFAULT_REDIRECT_URI = "http://localhost:53682/callback"; + +/** + * Resolve the scopes to request during the bootstrap flow. + * + * Scopes come exclusively from the `XERO_SCOPES` env var — the same var the + * running server honours — so the minted token carries exactly what the server + * will use. There is deliberately no built-in default list: requiring scopes to + * be set explicitly avoids silently over- or under-granting from a drifting + * hardcoded default. + * + * `offline_access` is appended automatically when absent — it is what yields the + * refresh token the self-refreshing client depends on; without it there is + * nothing to refresh. + */ +export function resolveScopes(): string { + const raw = (process.env.XERO_SCOPES ?? "").trim(); + if (!raw) { + throw new Error( + "XERO_SCOPES must be set to a space-separated list of scopes to run the " + + "auth flow (e.g. \"accounting.transactions accounting.contacts\"). " + + "offline_access is added automatically.", + ); + } + + const scopes = raw.split(/\s+/); + if (!scopes.includes("offline_access")) { + scopes.push("offline_access"); + } + return scopes.join(" "); +} diff --git a/src/index.ts b/src/index.ts index bf5c90aa..1af81b60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,20 @@ const main = async () => { await server.connect(transport); }; -main().catch((error) => { +// `auth` subcommand: run the one-time OAuth2 authorization-code bootstrap that +// mints the XERO_TOKEN_FILE the self-refreshing client then keeps alive. Kept +// out of the default path so the stdio transport stays untouched on normal runs. +const run = + process.argv[2] === "auth" + ? async () => { + const { runAuthorizationCodeFlow } = await import( + "./auth/bootstrap-auth.js" + ); + await runAuthorizationCodeFlow(); + } + : main; + +run().catch((error) => { console.error("Error:", error); process.exit(1); });