From b814d275d3418ffd030d01d4490813488506becb Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Sun, 26 Apr 2026 16:41:30 +0900 Subject: [PATCH] feat(auth): support Atlassian scoped API tokens via Cloud ID Atlassian's scoped API tokens require requests to go through the Platform API Gateway (api.atlassian.com/ex/jira/{cloudId}) instead of the site URL. This adds optional Cloud ID configuration that, when set, automatically routes all REST and Agile API calls through the gateway. - Accept cloudId via --cloud-id flag, JIRA_CLOUD_ID env var, or `jira config set cloudId` - Route REST (/rest/api/{2,3}) and Agile (/rest/agile/1.0) clients through the gateway base when cloudId is present - Keep existing direct routing when cloudId is empty (backward compatible with classic tokens, Jira Data Center, and Jira Server) - Surface scoped-token-specific hints on 401 responses Closes #28 --- README.md | 44 +++++++++++++++++++++++++++- bin/commands/config.js | 24 ++++++++++----- lib/config.js | 27 +++++++++++++++-- lib/jira-client.js | 28 ++++++++++++++++-- tests/commands/config.test.js | 30 +++++++++++++++++++ tests/config.test.js | 41 ++++++++++++++++++++++++++ tests/jira-client.test.js | 55 +++++++++++++++++++++++++++++++++++ 7 files changed, 236 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c7f1920..9432016 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ npm link export JIRA_API_TOKEN=your-api-token export JIRA_USERNAME=your-email@company.com export JIRA_API_VERSION=auto # optional: auto (default), 2, 3 + + # Scoped API token (Atlassian Cloud) — also set Cloud ID + export JIRA_CLOUD_ID=your-cloud-id # routes through Atlassian Platform API Gateway ``` 4. **Verify connection:** @@ -82,10 +85,11 @@ npm link ## Configuration -JIRA CLI supports two authentication modes: +JIRA CLI supports the following authentication modes: 1. **Bearer Token Authentication (Recommended)** - Uses API token directly 2. **Basic Authentication** - Uses username + API token (legacy) +3. **Scoped API Token (Atlassian Cloud)** - Basic auth + Cloud ID, routed through the Atlassian Platform API Gateway. Use this for Atlassian's newer scoped API tokens. ### Option 1: Command Line Configuration @@ -99,10 +103,17 @@ jira config --server https://yourcompany.atlassian.net \ --username your-email@company.com \ --token your-api-token +# Scoped API token (Atlassian Cloud) — set Cloud ID to route via the Platform API Gateway +jira config --server https://yourcompany.atlassian.net \ + --username your-email@company.com \ + --token your-scoped-api-token \ + --cloud-id your-cloud-id + # Or set individual values jira config set server https://yourcompany.atlassian.net jira config set token your-api-token jira config set username your-email@company.com # optional +jira config set cloudId your-cloud-id # optional, enables scoped tokens jira config set apiVersion auto # optional: auto (default), 2, 3 # Show current configuration @@ -143,6 +154,35 @@ export JIRA_API_TOKEN="your-api-token" export JIRA_API_VERSION="auto" # optional: auto (default), 2, 3 ``` +### Scoped API Tokens (Atlassian Cloud) + +Atlassian recommends using **scoped API tokens** over classic (unscoped) tokens. Scoped tokens require requests to go through the **Atlassian Platform API Gateway** at `https://api.atlassian.com/ex/jira/{cloudId}` instead of `https://{your-site}.atlassian.net`. + +To use a scoped token, configure your **Cloud ID** in addition to the usual server, username, and token: + +```bash +# Via flags +jira config --server https://yourcompany.atlassian.net \ + --username your-email@company.com \ + --token your-scoped-api-token \ + --cloud-id your-cloud-id + +# Or via env vars (works with both JIRA_HOST and JIRA_DOMAIN formats) +export JIRA_CLOUD_ID="your-cloud-id" +``` + +When `cloudId` is set, all REST API and Agile API requests are automatically routed through the Platform API Gateway. Without it, requests use the configured server URL directly (compatible with classic tokens, Jira Data Center, and Jira Server). + +#### Finding Your Cloud ID + +You can fetch your Cloud ID without authentication by visiting (or `curl`-ing): + +``` +https://your-site.atlassian.net/_edge/tenant_info +``` + +The response contains a `cloudId` field. Copy it into `--cloud-id` or `JIRA_CLOUD_ID`. + ### Getting Your API Token 1. Go to [Atlassian Account Settings](https://id.atlassian.com/manage-profile/security/api-tokens) @@ -150,6 +190,8 @@ export JIRA_API_VERSION="auto" # optional: auto (default), 2, 3 3. Give it a label (e.g., "jira-cli") 4. Copy the generated token +For **scoped tokens**, you can choose specific Jira scopes (e.g., `read:jira-work`, `write:jira-work`) when creating the token. Scoped tokens require Cloud ID configuration — see [Scoped API Tokens (Atlassian Cloud)](#scoped-api-tokens-atlassian-cloud). + ## Usage ### Read an Issue diff --git a/bin/commands/config.js b/bin/commands/config.js index 479268e..d8be846 100644 --- a/bin/commands/config.js +++ b/bin/commands/config.js @@ -8,6 +8,7 @@ function createConfigCommand(factory) { .option('--server ', 'set JIRA server URL') .option('--username ', 'set username') .option('--token ', 'set API token') + .option('--cloud-id ', 'set Atlassian Cloud ID for scoped API tokens') .action(async (options) => { const io = factory.getIOStreams(); const config = factory.getConfig(); @@ -22,23 +23,28 @@ function createConfigCommand(factory) { return; } - if (options.server || options.username || options.token) { + if (options.server || options.username || options.token || options.cloudId) { // Set individual configuration values if (options.server) { config.set('server', options.server.replace(/\/$/, '')); io.success(`Server set to: ${options.server}`); } - + if (options.username) { config.set('username', options.username); io.success(`Username set to: ${options.username}`); } - + if (options.token) { config.set('token', options.token); io.success('API token updated'); } + if (options.cloudId) { + config.set('cloudId', options.cloudId); + io.success(`Cloud ID set to: ${options.cloudId} (requests will route via Atlassian Platform API Gateway)`); + } + // Test connection if all required fields are present if (config.isConfigured()) { io.info('Testing connection...'); @@ -59,13 +65,17 @@ function createConfigCommand(factory) { ' jira config --server --token \n\n' + 'Basic authentication (optional):\n' + ' jira config --server --username --token \n\n' + + 'Scoped API token (Atlassian Cloud, recommended for new tokens):\n' + + ' jira config --server --username --token --cloud-id \n\n' + 'Or set using individual commands:\n' + ' jira config set server \n' + ' jira config set token \n' + - ' jira config set username # optional for Basic auth\n\n' + + ' jira config set username # optional for Basic auth\n' + + ' jira config set cloudId # optional, enables scoped tokens\n\n' + 'Or use environment variables:\n' + ' Bearer auth: export JIRA_HOST= JIRA_API_TOKEN=\n' + - ' Basic auth: export JIRA_HOST= JIRA_API_TOKEN= JIRA_USERNAME=' + ' Basic auth: export JIRA_HOST= JIRA_API_TOKEN= JIRA_USERNAME=\n' + + ' Scoped token: also export JIRA_CLOUD_ID=' ); } @@ -112,7 +122,7 @@ function createConfigCommand(factory) { io.success(`${key} set successfully`); // Test connection if setting critical values - if (['server', 'username', 'token'].includes(key) && config.isConfigured()) { + if (['server', 'username', 'token', 'cloudId'].includes(key) && config.isConfigured()) { io.info('Testing connection...'); const testResult = await config.testConfig(); @@ -149,7 +159,7 @@ function createConfigCommand(factory) { function getConfigAction(options) { if (options.show) return 'show'; - if (options.server || options.username || options.token) return 'set'; + if (options.server || options.username || options.token || options.cloudId) return 'set'; return 'interactive'; } diff --git a/lib/config.js b/lib/config.js index 823e283..2232375 100644 --- a/lib/config.js +++ b/lib/config.js @@ -17,6 +17,9 @@ class Config { token: { type: 'string' }, + cloudId: { + type: 'string' + }, apiVersion: { type: 'string', enum: ['auto', '2', '3'], @@ -60,6 +63,8 @@ class Config { // Get required configuration or throw error getRequiredConfig() { + const cloudId = process.env.JIRA_CLOUD_ID || this.get('cloudId') || ''; + // First try JIRA_HOST environment variables (new format) if (process.env.JIRA_HOST && process.env.JIRA_API_TOKEN) { return { @@ -68,6 +73,7 @@ class Config { `https://${process.env.JIRA_HOST}`, username: process.env.JIRA_USERNAME || '', // Empty username for token-only auth token: process.env.JIRA_API_TOKEN, + cloudId, apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto' }; } @@ -80,6 +86,7 @@ class Config { `https://${process.env.JIRA_DOMAIN}`, username: process.env.JIRA_USERNAME, token: process.env.JIRA_API_TOKEN, + cloudId, apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto' }; } @@ -91,9 +98,12 @@ class Config { ' ' + chalk.yellow('jira config --server --token ') + '\n' + 'For Basic auth, also provide:\n' + ' ' + chalk.yellow('jira config --username ') + '\n' + + 'For scoped API tokens (Atlassian Cloud), also provide:\n' + + ' ' + chalk.yellow('jira config --cloud-id ') + '\n' + 'Or use environment variables:\n' + ' Bearer auth: JIRA_HOST, JIRA_API_TOKEN\n' + - ' Basic auth: JIRA_HOST, JIRA_API_TOKEN, JIRA_USERNAME' + ' Basic auth: JIRA_HOST, JIRA_API_TOKEN, JIRA_USERNAME\n' + + ' Scoped token: add JIRA_CLOUD_ID' ); } @@ -101,6 +111,7 @@ class Config { server: this.get('server'), username: this.get('username') || '', token: this.get('token'), + cloudId, apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto' }; } @@ -145,20 +156,32 @@ class Config { console.log('Server:', chalk.green(process.env.JIRA_HOST)); console.log('Username:', chalk.green(process.env.JIRA_USERNAME || '(token auth)')); console.log('Token:', chalk.green('***configured***')); + if (process.env.JIRA_CLOUD_ID) { + console.log('Cloud ID:', chalk.green(process.env.JIRA_CLOUD_ID)); + console.log('Routing:', chalk.green('Atlassian Platform API Gateway (scoped token)')); + } console.log('API Version:', chalk.green(process.env.JIRA_API_VERSION || 'auto')); } else if (process.env.JIRA_DOMAIN) { console.log('Server:', chalk.green(process.env.JIRA_DOMAIN)); console.log('Username:', chalk.green(process.env.JIRA_USERNAME)); console.log('Token:', chalk.green('***configured***')); + if (process.env.JIRA_CLOUD_ID) { + console.log('Cloud ID:', chalk.green(process.env.JIRA_CLOUD_ID)); + console.log('Routing:', chalk.green('Atlassian Platform API Gateway (scoped token)')); + } console.log('API Version:', chalk.green(process.env.JIRA_API_VERSION || 'auto')); } } - + if (Object.keys(config).length > 0) { console.log(chalk.blue('\nFrom Config File:')); console.log('Server:', chalk.green(config.server || 'Not set')); console.log('Username:', chalk.green(config.username || '(Bearer auth)')); console.log('Token:', config.token ? chalk.green('Set (hidden)') : chalk.red('Not set')); + if (config.cloudId) { + console.log('Cloud ID:', chalk.green(config.cloudId)); + console.log('Routing:', chalk.green('Atlassian Platform API Gateway (scoped token)')); + } console.log('API Version:', chalk.green(config.apiVersion || 'auto')); } diff --git a/lib/jira-client.js b/lib/jira-client.js index 7d355ff..f75d1b6 100644 --- a/lib/jira-client.js +++ b/lib/jira-client.js @@ -1,9 +1,16 @@ const axios = require('axios'); +const ATLASSIAN_GATEWAY_HOST = 'https://api.atlassian.com'; + class JiraClient { constructor(config) { this.config = config; this.baseURL = config.server; + this.cloudId = (config.cloudId || '').trim(); + this.useGateway = this.cloudId.length > 0; + this.gatewayBase = this.useGateway + ? `${ATLASSIAN_GATEWAY_HOST}/ex/jira/${this.cloudId}` + : null; this.apiVersionMode = this.normalizeApiVersionMode(config.apiVersion || process.env.JIRA_API_VERSION); this.apiVersion = this.apiVersionMode === 'auto' ? 3 : this.apiVersionMode; this.axiosConfigKeys = new Set([ @@ -239,9 +246,14 @@ class JiraClient { return 'auto'; } + buildApiBaseUrl(suffix) { + const root = this.useGateway ? this.gatewayBase : this.baseURL; + return `${root}${suffix}`; + } + createApiClient(version, { auth, headers }) { const client = axios.create({ - baseURL: `${this.baseURL}/rest/api/${version}`, + baseURL: this.buildApiBaseUrl(`/rest/api/${version}`), auth, headers }); @@ -254,7 +266,7 @@ class JiraClient { createAgileClient({ auth, headers }) { const client = axios.create({ - baseURL: `${this.baseURL}/rest/agile/1.0`, + baseURL: this.buildApiBaseUrl('/rest/agile/1.0'), auth, headers }); @@ -287,7 +299,17 @@ class JiraClient { } formatJiraErrorMessage(status, data) { - if (status === 401) return 'Authentication failed. Please check your credentials.'; + if (status === 401) { + const base = 'Authentication failed. Please check your credentials.'; + if (this.useGateway) { + return base + + '\nUsing scoped API token via Atlassian Platform API Gateway.' + + '\n - Verify the token has the required Jira scopes (e.g., read:jira-work, write:jira-work)' + + '\n - Verify your Cloud ID is correct' + + '\n - Verify the email matches the account that created the token'; + } + return base; + } if (status === 403) return 'Access denied. You don\'t have permission to perform this action.'; if (status === 404) return 'Resource not found.'; return data?.errorMessages ? data.errorMessages.join(', ') : 'API request failed'; diff --git a/tests/commands/config.test.js b/tests/commands/config.test.js index 557739a..a5fcceb 100644 --- a/tests/commands/config.test.js +++ b/tests/commands/config.test.js @@ -83,6 +83,11 @@ describe('ConfigCommand', () => { const tokenOption = configCommand.options.find(opt => opt.long === '--token'); expect(tokenOption).toBeDefined(); }); + + it('should have cloud-id option', () => { + const cloudIdOption = configCommand.options.find(opt => opt.long === '--cloud-id'); + expect(cloudIdOption).toBeDefined(); + }); }); describe('Bearer authentication support', () => { @@ -114,4 +119,29 @@ describe('ConfigCommand', () => { expect(mockConfig.testConfig).toHaveBeenCalled(); }); }); + + describe('scoped API token (--cloud-id) support', () => { + it('should set cloudId when --cloud-id is provided', async () => { + mockConfig.isConfigured.mockReturnValue(false); + + await configCommand.parseAsync(['node', 'test', + '--server', 'https://test.atlassian.net', + '--username', 'test@example.com', + '--token', 'scoped-token', + '--cloud-id', 'abcd-1234' + ]); + + expect(mockConfig.set).toHaveBeenCalledWith('cloudId', 'abcd-1234'); + }); + + it('should accept --cloud-id alone without other flags', async () => { + mockConfig.isConfigured.mockReturnValue(false); + + await configCommand.parseAsync(['node', 'test', + '--cloud-id', 'abcd-1234' + ]); + + expect(mockConfig.set).toHaveBeenCalledWith('cloudId', 'abcd-1234'); + }); + }); }); diff --git a/tests/config.test.js b/tests/config.test.js index 7cb999d..d6d4daa 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -11,6 +11,7 @@ describe('Config', () => { delete process.env.JIRA_DOMAIN; delete process.env.JIRA_USERNAME; delete process.env.JIRA_API_TOKEN; + delete process.env.JIRA_CLOUD_ID; }); describe('constructor', () => { @@ -204,4 +205,44 @@ describe('Config', () => { expect(config.isConfigured()).toBe(true); }); }); + + describe('scoped API token (cloudId) support', () => { + it('should default cloudId to empty string when not set', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('token', 'testtoken'); + + const requiredConfig = config.getRequiredConfig(); + expect(requiredConfig.cloudId).toBe(''); + }); + + it('should return cloudId from stored config', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('username', 'test@example.com'); + config.set('token', 'scoped-token'); + config.set('cloudId', 'abcd-1234'); + + const requiredConfig = config.getRequiredConfig(); + expect(requiredConfig.cloudId).toBe('abcd-1234'); + }); + + it('should prefer JIRA_CLOUD_ID env var over stored config', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('token', 'testtoken'); + config.set('cloudId', 'stored-cloud-id'); + process.env.JIRA_CLOUD_ID = 'env-cloud-id'; + + const requiredConfig = config.getRequiredConfig(); + expect(requiredConfig.cloudId).toBe('env-cloud-id'); + }); + + it('should pick up cloudId via JIRA_CLOUD_ID alongside JIRA_HOST env vars', () => { + process.env.JIRA_HOST = 'https://test.atlassian.net'; + process.env.JIRA_API_TOKEN = 'scoped-token'; + process.env.JIRA_CLOUD_ID = 'env-cloud-id'; + + const requiredConfig = config.getRequiredConfig(); + expect(requiredConfig.cloudId).toBe('env-cloud-id'); + expect(requiredConfig.server).toBe('https://test.atlassian.net'); + }); + }); }); diff --git a/tests/jira-client.test.js b/tests/jira-client.test.js index e27d2d0..f272b91 100644 --- a/tests/jira-client.test.js +++ b/tests/jira-client.test.js @@ -84,6 +84,61 @@ describe('JiraClient', () => { expect(basicClient.clientV2.defaults.headers['Authorization']).toBeUndefined(); expect(basicClient.clientV3.defaults.headers['Authorization']).toBeUndefined(); }); + + test('should route through Atlassian Platform API Gateway when cloudId is set', () => { + const scopedConfig = { + server: 'https://test.atlassian.net', + username: 'test@example.com', + token: 'scoped-token', + cloudId: 'abcd-1234-cloud-id' + }; + + const scopedClient = new JiraClient(scopedConfig); + + expect(scopedClient.useGateway).toBe(true); + expect(scopedClient.cloudId).toBe('abcd-1234-cloud-id'); + expect(scopedClient.clientV2.defaults.baseURL).toBe( + 'https://api.atlassian.com/ex/jira/abcd-1234-cloud-id/rest/api/2' + ); + expect(scopedClient.clientV3.defaults.baseURL).toBe( + 'https://api.atlassian.com/ex/jira/abcd-1234-cloud-id/rest/api/3' + ); + expect(scopedClient.agileClient.defaults.baseURL).toBe( + 'https://api.atlassian.com/ex/jira/abcd-1234-cloud-id/rest/agile/1.0' + ); + }); + + test('should keep direct routing when cloudId is empty or missing', () => { + const directConfig = { + server: 'https://test.atlassian.net', + username: 'test@example.com', + token: 'classic-token', + cloudId: '' + }; + + const directClient = new JiraClient(directConfig); + + expect(directClient.useGateway).toBe(false); + expect(directClient.clientV2.defaults.baseURL).toBe('https://test.atlassian.net/rest/api/2'); + expect(directClient.clientV3.defaults.baseURL).toBe('https://test.atlassian.net/rest/api/3'); + expect(directClient.agileClient.defaults.baseURL).toBe('https://test.atlassian.net/rest/agile/1.0'); + }); + + test('should preserve auth credentials when routing through gateway', () => { + const scopedConfig = { + server: 'https://test.atlassian.net', + username: 'test@example.com', + token: 'scoped-token', + cloudId: 'abcd-1234' + }; + + const scopedClient = new JiraClient(scopedConfig); + + expect(scopedClient.clientV3.defaults.auth).toEqual({ + username: 'test@example.com', + password: 'scoped-token' + }); + }); }); describe('API methods', () => {