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,