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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ This opens your browser for Xero's OAuth consent flow. After you authorize, the

Tokens are encrypted (AES-256-GCM) and cached at `~/.config/xero-command-line/tokens.json`. Tokens refresh automatically when expired.

### Remote or headless login

If the CLI is running on a remote machine that cannot open a browser, use `--no-open` to print the authorization URL instead:

```bash
xero login --no-open
```

The CLI still listens for the OAuth callback on `http://localhost:8742/callback`. Forward that port from your local machine when connecting over SSH:

```bash
ssh -L 8742:127.0.0.1:8742 user@remote-host
```

In that SSH session, run `xero login --no-open`, then open the printed URL in your local browser. The browser's callback travels through the SSH tunnel to the CLI. Keep the SSH session and login command running until authentication completes.

This option avoids launching a browser on the remote machine; it does not provide unattended or browser-free authentication.

### Token storage

The CLI encrypts OAuth tokens at rest in `~/.config/xero-command-line/tokens.json`. Where the **encryption key** is stored, from most to least secure:
Expand Down
18 changes: 15 additions & 3 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import {performLogin} from '../lib/oauth.js'
import {cacheTokenSet} from '../lib/auth.js'

export default class Login extends BaseCommand {
static override description = 'Log in to Xero via browser (PKCE OAuth)'
static override description = 'Log in to Xero via PKCE OAuth'

static override examples = [
'<%= config.bin %> login',
'<%= config.bin %> login -p acme-corp',
'<%= config.bin %> login --no-open',
'<%= config.bin %> login --scope "accounting.transactions.read accounting.contacts.read accounting.reports.read"',
]

Expand All @@ -26,15 +27,26 @@ export default class Login extends BaseCommand {
description: 'Override OAuth scopes (space-separated). openid/profile/email/offline_access auto-prepended.',
env: 'XERO_SCOPES',
}),
'no-open': Flags.boolean({
description: 'Print the authorization URL instead of opening a browser',
default: false,
}),
}

async run(): Promise<void> {
const {flags} = await this.parse(Login)
const {profileName, clientId} = this.resolveCredentials(flags)

this.log('Opening browser for Xero login...')
if (flags['no-open']) {
this.log('Open this URL in a browser:')
} else {
this.log('Opening browser for Xero login...')
}

const {tokenSet, tenantId, tenantName} = await performLogin(clientId, flags.scope)
const {tokenSet, tenantId, tenantName} = await performLogin(clientId, flags.scope, {
openBrowser: !flags['no-open'],
onAuthorizationUrl: url => this.log(url),
})
await cacheTokenSet(profileName, tokenSet, tenantId, tenantName)

this.log(`Logged in to ${tenantName}`)
Expand Down
15 changes: 12 additions & 3 deletions src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ interface XeroTenant {
tenantName: string
}

export interface PerformLoginOptions {
openBrowser?: boolean
onAuthorizationUrl?: (url: string) => void
}

function generateCodeVerifier(): string {
return randomBytes(32).toString('base64url')
}
Expand Down Expand Up @@ -169,9 +174,8 @@ async function fetchTenants(accessToken: string): Promise<XeroTenant[]> {
export async function performLogin(
clientId: string,
scopes?: string,
options: PerformLoginOptions = {},
): Promise<{tokenSet: TokenSet; tenantId: string; tenantName: string}> {
const {default: open} = await import('open')

const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = randomBytes(16).toString('hex')
Expand All @@ -180,7 +184,12 @@ export async function performLogin(

// Start the callback server before opening the browser
const codePromise = waitForCallback(state)
await open(authUrl)
if (options.openBrowser === false) {
options.onAuthorizationUrl?.(authUrl)
} else {
const {default: open} = await import('open')
await open(authUrl)
}

const code = await codePromise
const tokenSet = await exchangeCodeForTokens(clientId, code, codeVerifier)
Expand Down
58 changes: 58 additions & 0 deletions test/commands/login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {beforeEach, describe, expect, it, vi} from 'vitest'
import type {Config} from '@oclif/core'

const mocks = vi.hoisted(() => ({
cacheTokenSet: vi.fn(),
performLogin: vi.fn(async (
_clientId: string,
_scopes?: string,
options?: {openBrowser?: boolean; onAuthorizationUrl?: (url: string) => void},
) => {
if (options?.openBrowser === false) {
options.onAuthorizationUrl?.('https://login.xero.test/authorize')
}

return {
tenantId: 'tenant-id',
tenantName: 'Demo Company',
tokenSet: {access_token: 'access-token'},
}
}),
}))

vi.mock('../../src/lib/auth.js', () => ({
cacheTokenSet: mocks.cacheTokenSet,
}))

vi.mock('../../src/lib/oauth.js', () => ({
performLogin: mocks.performLogin,
}))

const {default: Login} = await import('../../src/commands/login.js')

describe('login command', () => {
beforeEach(() => {
mocks.cacheTokenSet.mockClear()
mocks.performLogin.mockClear()
vi.restoreAllMocks()
})

it('prints an authorization URL instead of opening a browser with --no-open', async () => {
const messages: string[] = []
vi.spyOn(Login.prototype, 'log').mockImplementation((message = '') => messages.push(message))

const command = new Login(
['--client-id', 'client-id', '--no-open'],
{
runHook: vi.fn(async () => ({successes: []})),
} as unknown as Config,
)
const result = await command.run()
.then(() => ({ok: true}), error => ({error, ok: false}))

expect(result).toEqual({ok: true})
expect(messages).toContain('Open this URL in a browser:')
expect(messages).toContain('https://login.xero.test/authorize')
expect(messages).not.toContain('Opening browser for Xero login...')
})
})
93 changes: 93 additions & 0 deletions test/lib/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {beforeEach, describe, expect, it, vi} from 'vitest'

const mocks = vi.hoisted(() => ({
openBrowser: vi.fn<(_: string) => Promise<void>>(),
}))

vi.mock('open', () => ({
default: mocks.openBrowser,
}))

vi.mock('node:crypto', async () => {
const actual = await vi.importActual<typeof import('node:crypto')>('node:crypto')
return {
...actual,
randomBytes: (size: number) => Buffer.alloc(size, size === 16 ? 2 : 1),
}
})

vi.mock('node:http', () => ({
createServer: (listener: (request: {url: string}, response: FakeResponse) => void) => {
const server = {
close: vi.fn(),
listen: vi.fn((_port: number, _host: string, onListening: () => void) => {
onListening()
listener(
{url: '/callback?code=authorization-code&state=02020202020202020202020202020202'},
{end: vi.fn(), writeHead: vi.fn()},
)
}),
on: vi.fn(),
}

return server
},
}))

interface FakeResponse {
end: ReturnType<typeof vi.fn>
writeHead: ReturnType<typeof vi.fn>
}

const {performLogin} = await import('../../src/lib/oauth.js')

describe('performLogin', () => {
beforeEach(() => {
mocks.openBrowser.mockReset()
mocks.openBrowser.mockResolvedValue()

vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({access_token: 'access-token'}), {
headers: {'Content-Type': 'application/json'},
status: 200,
}))
.mockResolvedValueOnce(new Response(JSON.stringify([{
authEventId: 'auth-event-id',
id: 'connection-id',
tenantId: 'tenant-id',
tenantName: 'Demo Company',
tenantType: 'ORGANISATION',
}]), {
headers: {'Content-Type': 'application/json'},
status: 200,
})))
})

it('opens the authorization URL by default', async () => {
await performLogin('client-id')

expect(mocks.openBrowser).toHaveBeenCalledTimes(1)
expect(mocks.openBrowser).toHaveBeenCalledWith(expect.stringMatching(
/^https:\/\/login\.xero\.com\/identity\/connect\/authorize\?/,
))
})

it('returns the authorization URL without opening a browser in manual mode', async () => {
const authorizationUrls: string[] = []

await performLogin('client-id', undefined, {
openBrowser: false,
onAuthorizationUrl: (url: string) => authorizationUrls.push(url),
})

expect(mocks.openBrowser).not.toHaveBeenCalled()
expect(authorizationUrls).toHaveLength(1)

const authorizationUrl = new URL(authorizationUrls[0])
expect(authorizationUrl.origin).toBe('https://login.xero.com')
expect(authorizationUrl.pathname).toBe('/identity/connect/authorize')
expect(authorizationUrl.searchParams.get('client_id')).toBe('client-id')
expect(authorizationUrl.searchParams.get('redirect_uri')).toBe('http://localhost:8742/callback')
expect(authorizationUrl.searchParams.get('state')).toBe('02020202020202020202020202020202')
})
})