From f44311f35dd96d2fbdd318fe4494612609aff86b Mon Sep 17 00:00:00 2001 From: Brad Stanfield Date: Mon, 4 May 2026 11:23:33 +1200 Subject: [PATCH] Add --scope flag to login (env: XERO_SCOPES) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows callers to override the hardcoded SCOPES list when authenticating. Required openid/profile/email/offline_access are auto-prepended via a new resolveScopes() helper to prevent footguns (refresh tokens require offline_access). Also extracts REQUIRED_OAUTH_SCOPES as a const so the existing SCOPES list spreads from it — single source of truth, no drift if one is edited later. Use case: Xero apps created after 2 March 2026 cannot grant umbrella scopes like accounting.transactions or accounting.journals.read on the Starter plan, so the hardcoded SCOPES set causes login to fail with 'unauthorized_client'. This flag lets users request a narrower scope set (e.g. read-only granular scopes for a finance dashboard / agent integration) without forking. --- src/commands/login.ts | 7 ++++++- src/lib/oauth.ts | 15 ++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index a3a509a..53dd2a4 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -9,6 +9,7 @@ export default class Login extends BaseCommand { static override examples = [ '<%= config.bin %> login', '<%= config.bin %> login -p acme-corp', + '<%= config.bin %> login --scope "accounting.transactions.read accounting.contacts.read accounting.reports.read"', ] static override flags = { @@ -21,6 +22,10 @@ export default class Login extends BaseCommand { description: 'Xero client ID (overrides profile)', env: 'XERO_CLIENT_ID', }), + scope: Flags.string({ + description: 'Override OAuth scopes (space-separated). openid/profile/email/offline_access auto-prepended.', + env: 'XERO_SCOPES', + }), } async run(): Promise { @@ -29,7 +34,7 @@ export default class Login extends BaseCommand { this.log('Opening browser for Xero login...') - const {tokenSet, tenantId, tenantName} = await performLogin(clientId) + const {tokenSet, tenantId, tenantName} = await performLogin(clientId, flags.scope) await cacheTokenSet(profileName, tokenSet, tenantId, tenantName) this.log(`Logged in to ${tenantName}`) diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index e90f0d5..1d56402 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -7,11 +7,10 @@ const XERO_TOKEN_BASE = 'https://identity.xero.com' const REDIRECT_URI = 'http://localhost:8742/callback' const CALLBACK_TIMEOUT_MS = 120_000 +const REQUIRED_OAUTH_SCOPES = ['openid', 'profile', 'email', 'offline_access'] + const SCOPES = [ - 'openid', - 'profile', - 'email', - 'offline_access', + ...REQUIRED_OAUTH_SCOPES, // Contacts & settings 'accounting.contacts', 'accounting.settings', @@ -56,12 +55,18 @@ function generateCodeChallenge(verifier: string): string { return createHash('sha256').update(verifier).digest('base64url') } +function resolveScopes(scopes?: string): string { + if (!scopes) return SCOPES + const requested = scopes.split(/\s+/).filter(Boolean) + return [...new Set([...REQUIRED_OAUTH_SCOPES, ...requested])].join(' ') +} + function buildAuthUrl(clientId: string, codeChallenge: string, state: string, scopes?: string): string { const params = new URLSearchParams({ response_type: 'code', client_id: clientId, redirect_uri: REDIRECT_URI, - scope: scopes ?? SCOPES, + scope: resolveScopes(scopes), code_challenge: codeChallenge, code_challenge_method: 'S256', state,