diff --git a/README.md b/README.md index 9432016..e825ff6 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,34 @@ https://your-site.atlassian.net/_edge/tenant_info The response contains a `cloudId` field. Copy it into `--cloud-id` or `JIRA_CLOUD_ID`. +### mTLS Authentication (Client Certificate) + +For self-hosted or reverse-proxied JIRA deployments that authenticate at the TLS layer with client certificates: + +#### Command Line Configuration +```bash +jira config --server https://jira.example.com \ + --auth-type mtls \ + --tls-client-cert ~/.certs/client.pem \ + --tls-client-key ~/.certs/client.key \ + --tls-ca-cert ~/.certs/ca-chain.pem +``` + +#### Environment Variables +```bash +export JIRA_HOST="jira.example.com" +export JIRA_AUTH_TYPE="mtls" +export JIRA_TLS_CLIENT_CERT="~/.certs/client.pem" +export JIRA_TLS_CLIENT_KEY="~/.certs/client.key" +export JIRA_TLS_CA_CERT="~/.certs/ca-chain.pem" # optional +``` + +**Notes:** +- mTLS mode does not send an `Authorization` header; authentication happens at the TLS layer +- The CA certificate is optional if your client certificate is signed by a well-known CA +- mTLS is commonly used in enterprise environments with private certificate authorities +- Paths beginning with `~/` are expanded to your home directory; certificate files are read at startup, so update the cert paths if they change + ### Getting Your API Token 1. Go to [Atlassian Account Settings](https://id.atlassian.com/manage-profile/security/api-tokens) diff --git a/bin/commands/config.js b/bin/commands/config.js index d8be846..f319a4f 100644 --- a/bin/commands/config.js +++ b/bin/commands/config.js @@ -1,4 +1,6 @@ const { Command } = require('commander'); +const fs = require('fs'); +const { expandHomePath } = require('../../lib/utils'); function createConfigCommand(factory) { const command = new Command('config') @@ -9,21 +11,34 @@ function createConfigCommand(factory) { .option('--username ', 'set username') .option('--token ', 'set API token') .option('--cloud-id ', 'set Atlassian Cloud ID for scoped API tokens') + .option('--auth-type ', 'authentication type (basic, bearer, or mtls)') + .option('--tls-client-cert ', 'client certificate for mTLS authentication') + .option('--tls-client-key ', 'client private key for mTLS authentication') + .option('--tls-ca-cert ', 'CA certificate for mTLS authentication (optional)') .action(async (options) => { const io = factory.getIOStreams(); const config = factory.getConfig(); const analytics = factory.getAnalytics(); - + try { await analytics.track('config', { action: getConfigAction(options) }); - + if (options.show) { // Show current configuration config.displayConfig(); return; } - if (options.server || options.username || options.token || options.cloudId) { + if ( + options.server || + options.username || + options.token || + options.cloudId || + options.authType || + options.tlsClientCert || + options.tlsClientKey || + options.tlsCaCert + ) { // Set individual configuration values if (options.server) { config.set('server', options.server.replace(/\/$/, '')); @@ -45,6 +60,40 @@ function createConfigCommand(factory) { io.success(`Cloud ID set to: ${options.cloudId} (requests will route via Atlassian Platform API Gateway)`); } + if (options.authType) { + const authType = options.authType.toLowerCase(); + if (!['basic', 'bearer', 'mtls'].includes(authType)) { + throw new Error('--auth-type must be "basic", "bearer", or "mtls"'); + } + config.set('authType', authType); + io.success(`Auth type set to: ${authType}`); + } + + // mTLS certificate configuration + if (options.tlsClientCert) { + if (!fs.existsSync(expandHomePath(options.tlsClientCert))) { + throw new Error(`Client certificate file not found: ${options.tlsClientCert}`); + } + config.set('tlsClientCert', options.tlsClientCert); + io.success('TLS client certificate configured'); + } + + if (options.tlsClientKey) { + if (!fs.existsSync(expandHomePath(options.tlsClientKey))) { + throw new Error(`Client key file not found: ${options.tlsClientKey}`); + } + config.set('tlsClientKey', options.tlsClientKey); + io.success('TLS client key configured'); + } + + if (options.tlsCaCert) { + if (!fs.existsSync(expandHomePath(options.tlsCaCert))) { + throw new Error(`CA certificate file not found: ${options.tlsCaCert}`); + } + config.set('tlsCaCert', options.tlsCaCert); + io.success('TLS CA certificate configured'); + } + // Test connection if all required fields are present if (config.isConfigured()) { io.info('Testing connection...'); @@ -67,6 +116,11 @@ function createConfigCommand(factory) { ' 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' + + 'mTLS authentication (for self-hosted/reverse-proxied Jira):\n' + + ' jira config --server --auth-type mtls \\\n' + + ' --tls-client-cert /path/to/client.pem \\\n' + + ' --tls-client-key /path/to/client.key \\\n' + + ' --tls-ca-cert /path/to/ca.pem\n\n' + 'Or set using individual commands:\n' + ' jira config set server \n' + ' jira config set token \n' + @@ -75,7 +129,9 @@ function createConfigCommand(factory) { 'Or use environment variables:\n' + ' Bearer auth: export JIRA_HOST= JIRA_API_TOKEN=\n' + ' Basic auth: export JIRA_HOST= JIRA_API_TOKEN= JIRA_USERNAME=\n' + - ' Scoped token: also export JIRA_CLOUD_ID=' + ' Scoped token: also export JIRA_CLOUD_ID=\n' + + ' mTLS auth: export JIRA_HOST= JIRA_AUTH_TYPE=mtls \\\n' + + ' JIRA_TLS_CLIENT_CERT= JIRA_TLS_CLIENT_KEY=' ); } @@ -92,7 +148,7 @@ function createConfigCommand(factory) { .action(async (key) => { const io = factory.getIOStreams(); const config = factory.getConfig(); - + try { if (key) { const value = config.get(key); @@ -116,16 +172,16 @@ function createConfigCommand(factory) { .action(async (key, value) => { const io = factory.getIOStreams(); const config = factory.getConfig(); - + try { config.set(key, value); io.success(`${key} set successfully`); - + // Test connection if setting critical values if (['server', 'username', 'token', 'cloudId'].includes(key) && config.isConfigured()) { io.info('Testing connection...'); const testResult = await config.testConfig(); - + if (testResult.success) { io.success('Connection verified'); } else { @@ -144,7 +200,7 @@ function createConfigCommand(factory) { .action(async (key) => { const io = factory.getIOStreams(); const config = factory.getConfig(); - + try { config.delete(key); io.success(`${key} unset successfully`); @@ -159,7 +215,18 @@ function createConfigCommand(factory) { function getConfigAction(options) { if (options.show) return 'show'; - if (options.server || options.username || options.token || options.cloudId) return 'set'; + if ( + options.server || + options.username || + options.token || + options.cloudId || + options.authType || + options.tlsClientCert || + options.tlsClientKey || + options.tlsCaCert + ) { + return 'set'; + } return 'interactive'; } diff --git a/lib/config.js b/lib/config.js index 2232375..e844651 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,6 +1,8 @@ const Conf = require('conf'); const chalk = require('chalk'); +const fs = require('fs'); const JiraClient = require('./jira-client'); +const { expandHomePath } = require('./utils'); class Config { constructor() { @@ -20,6 +22,19 @@ class Config { cloudId: { type: 'string' }, + authType: { + type: 'string', + enum: ['basic', 'bearer', 'mtls'] + }, + tlsClientCert: { + type: 'string' + }, + tlsClientKey: { + type: 'string' + }, + tlsCaCert: { + type: 'string' + }, apiVersion: { type: 'string', enum: ['auto', '2', '3'], @@ -52,20 +67,92 @@ class Config { return this.config.has(key); } + // Like has(), but also requires the stored value to be a non-empty string. + // Prevents configurations where a key exists but was set to '' from passing + // validation checks (e.g. `jira config --username ''`). + hasNonEmpty(key) { + const v = this.get(key); + return typeof v === 'string' && v.trim().length > 0; + } + // Check if all required config is present isConfigured() { - return !!( - (this.has('server') && this.has('token')) || - (process.env.JIRA_HOST && process.env.JIRA_API_TOKEN) || - (process.env.JIRA_DOMAIN && process.env.JIRA_USERNAME && process.env.JIRA_API_TOKEN) - ); + // Environment variables take precedence, in priority order. + // + // When env explicitly selects mTLS, that choice is authoritative - don't + // fall through to the JIRA_API_TOKEN path just because a stale token is + // still in the environment. + if (process.env.JIRA_HOST && process.env.JIRA_AUTH_TYPE === 'mtls') { + return Boolean(process.env.JIRA_TLS_CLIENT_CERT && process.env.JIRA_TLS_CLIENT_KEY); + } + + if (process.env.JIRA_HOST && process.env.JIRA_API_TOKEN) return true; + if (process.env.JIRA_DOMAIN && process.env.JIRA_USERNAME && process.env.JIRA_API_TOKEN) return true; + + // Stored config must match the selected auth mode + if (!this.has('server')) return false; + + const authType = this.get('authType'); + + if (authType === 'mtls') { + return this.hasNonEmpty('tlsClientCert') && this.hasNonEmpty('tlsClientKey'); + } + if (authType === 'basic') { + return this.hasNonEmpty('username') && this.hasNonEmpty('token'); + } + if (authType === 'bearer') { + return this.hasNonEmpty('token'); + } + + // Legacy config (no authType): token is sufficient; username is optional + return this.hasNonEmpty('token'); + } + + // Validate mTLS configuration + validateMtlsConfig(mtls) { + const errors = []; + if (!mtls.clientCert) { + errors.push('mTLS requires a client certificate (--tls-client-cert or JIRA_TLS_CLIENT_CERT)'); + } else if (!fs.existsSync(expandHomePath(mtls.clientCert))) { + errors.push(`Client certificate file not found: ${mtls.clientCert}`); + } + if (!mtls.clientKey) { + errors.push('mTLS requires a client key (--tls-client-key or JIRA_TLS_CLIENT_KEY)'); + } else if (!fs.existsSync(expandHomePath(mtls.clientKey))) { + errors.push(`Client key file not found: ${mtls.clientKey}`); + } + if (mtls.caCert && !fs.existsSync(expandHomePath(mtls.caCert))) { + errors.push(`CA certificate file not found: ${mtls.caCert}`); + } + return errors; } // 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) + // First check for mTLS environment variables + if (process.env.JIRA_HOST && process.env.JIRA_AUTH_TYPE === 'mtls') { + const mtls = { + clientCert: process.env.JIRA_TLS_CLIENT_CERT, + clientKey: process.env.JIRA_TLS_CLIENT_KEY, + caCert: process.env.JIRA_TLS_CA_CERT + }; + const errors = this.validateMtlsConfig(mtls); + if (errors.length > 0) { + throw new Error('mTLS configuration error:\n ' + errors.join('\n ')); + } + return { + server: process.env.JIRA_HOST.startsWith('http') ? + process.env.JIRA_HOST : + `https://${process.env.JIRA_HOST}`, + authType: 'mtls', + mtls, + apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto' + }; + } + + // Try JIRA_HOST environment variables (new format) if (process.env.JIRA_HOST && process.env.JIRA_API_TOKEN) { return { server: process.env.JIRA_HOST.startsWith('http') ? @@ -74,6 +161,7 @@ class Config { username: process.env.JIRA_USERNAME || '', // Empty username for token-only auth token: process.env.JIRA_API_TOKEN, cloudId, + authType: process.env.JIRA_USERNAME ? 'basic' : 'bearer', apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto' }; } @@ -87,12 +175,57 @@ class Config { username: process.env.JIRA_USERNAME, token: process.env.JIRA_API_TOKEN, cloudId, + authType: 'basic', + apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto' + }; + } + + // Fall back to stored config - check for mTLS first + if (this.has('server') && this.get('authType') === 'mtls') { + const mtls = { + clientCert: this.get('tlsClientCert'), + clientKey: this.get('tlsClientKey'), + caCert: this.get('tlsCaCert') + }; + const errors = this.validateMtlsConfig(mtls); + if (errors.length > 0) { + throw new Error('mTLS configuration error:\n ' + errors.join('\n ')); + } + return { + server: this.get('server'), + authType: 'mtls', + mtls, + apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto' + }; + } + + // Explicit basic auth requires both username and token - fail fast rather + // than silently falling back to bearer. + if (this.has('server') && this.get('authType') === 'basic') { + const missing = []; + if (!this.hasNonEmpty('username')) missing.push('username (--username )'); + if (!this.hasNonEmpty('token')) missing.push('token (--token )'); + if (missing.length > 0) { + throw new Error( + 'Basic auth configuration is incomplete. Missing: ' + missing.join(', ') + '.\n' + + 'Set with:\n' + + ' ' + chalk.yellow('jira config --username --token ') + '\n' + + 'Or switch auth types with:\n' + + ' ' + chalk.yellow('jira config --auth-type bearer') + ); + } + return { + server: this.get('server'), + username: this.get('username'), + token: this.get('token'), + cloudId, + authType: 'basic', apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto' }; } - // Fall back to stored config - if (!this.has('server') || !this.has('token')) { + // Standard token-based config (explicit bearer or legacy inference) + if (!this.has('server') || !this.hasNonEmpty('token')) { throw new Error( 'JIRA CLI is not configured. Set configuration using:\n' + ' ' + chalk.yellow('jira config --server --token ') + '\n' + @@ -100,18 +233,23 @@ class Config { ' ' + chalk.yellow('jira config --username ') + '\n' + 'For scoped API tokens (Atlassian Cloud), also provide:\n' + ' ' + chalk.yellow('jira config --cloud-id ') + '\n' + + 'For mTLS auth:\n' + + ' ' + chalk.yellow('jira config --server --auth-type mtls --tls-client-cert --tls-client-key ') + '\n' + 'Or use environment variables:\n' + ' Bearer auth: JIRA_HOST, JIRA_API_TOKEN\n' + ' Basic auth: JIRA_HOST, JIRA_API_TOKEN, JIRA_USERNAME\n' + - ' Scoped token: add JIRA_CLOUD_ID' + ' Scoped token: add JIRA_CLOUD_ID\n' + + ' mTLS auth: JIRA_HOST, JIRA_AUTH_TYPE=mtls, JIRA_TLS_CLIENT_CERT, JIRA_TLS_CLIENT_KEY' ); } + const authType = this.get('authType') || (this.get('username') ? 'basic' : 'bearer'); return { server: this.get('server'), username: this.get('username') || '', token: this.get('token'), cloudId, + authType, apiVersion: process.env.JIRA_API_VERSION || this.get('apiVersion') || 'auto' }; } @@ -140,7 +278,8 @@ class Config { displayConfig() { const config = this.get(); const hasEnvConfig = (process.env.JIRA_HOST && process.env.JIRA_API_TOKEN) || - (process.env.JIRA_DOMAIN && process.env.JIRA_USERNAME && process.env.JIRA_API_TOKEN); + (process.env.JIRA_DOMAIN && process.env.JIRA_USERNAME && process.env.JIRA_API_TOKEN) || + (process.env.JIRA_HOST && process.env.JIRA_AUTH_TYPE === 'mtls'); if (Object.keys(config).length === 0 && !hasEnvConfig) { console.log(chalk.yellow('No configuration found.')); @@ -149,16 +288,25 @@ class Config { } console.log(chalk.bold('\nCurrent JIRA Configuration:')); - + if (hasEnvConfig) { console.log(chalk.blue('\nFrom Environment Variables:')); if (process.env.JIRA_HOST) { 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)')); + if (process.env.JIRA_AUTH_TYPE === 'mtls') { + console.log('Auth Type:', chalk.green('mTLS (client certificate)')); + console.log('Client Cert:', chalk.green(process.env.JIRA_TLS_CLIENT_CERT || 'Not set')); + console.log('Client Key:', chalk.green(process.env.JIRA_TLS_CLIENT_KEY ? '***configured***' : 'Not set')); + if (process.env.JIRA_TLS_CA_CERT) { + console.log('CA Cert:', chalk.green(process.env.JIRA_TLS_CA_CERT)); + } + } else { + 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) { @@ -176,15 +324,25 @@ class Config { 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)')); + const authType = config.authType || (config.username ? 'basic' : 'bearer'); + console.log('Auth Type:', chalk.green(authType)); + if (authType === 'mtls') { + console.log('Client Cert:', chalk.green(config.tlsClientCert || 'Not set')); + console.log('Client Key:', config.tlsClientKey ? chalk.green('***configured***') : chalk.red('Not set')); + if (config.tlsCaCert) { + console.log('CA Cert:', chalk.green(config.tlsCaCert)); + } + } else { + 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')); } - + if (this.isConfigured()) { console.log('\n' + chalk.green('✓ Configuration is complete')); } else { diff --git a/lib/jira-client.js b/lib/jira-client.js index f75d1b6..ceaa376 100644 --- a/lib/jira-client.js +++ b/lib/jira-client.js @@ -1,4 +1,7 @@ const axios = require('axios'); +const fs = require('fs'); +const https = require('https'); +const { expandHomePath } = require('./utils'); const ATLASSIAN_GATEWAY_HOST = 'https://api.atlassian.com'; @@ -13,6 +16,21 @@ class JiraClient { : null; this.apiVersionMode = this.normalizeApiVersionMode(config.apiVersion || process.env.JIRA_API_VERSION); this.apiVersion = this.apiVersionMode === 'auto' ? 3 : this.apiVersionMode; + this.authTypeExplicit = typeof config.authType === 'string' && config.authType.length > 0; + this.authType = (config.authType || (config.username ? 'basic' : 'bearer')).toLowerCase(); + this.mtls = config.mtls; + + // Fail fast when the caller explicitly selected basic auth but provided no + // username - previously this silently fell through to Bearer auth, which + // is surprising and can mask misconfiguration. + if (this.authTypeExplicit && this.authType === 'basic' && (!config.username || config.username === '')) { + throw new Error( + 'Basic auth requires a username. Set one with:\n' + + ' jira config --username \n' + + 'Or switch auth types with:\n' + + ' jira config --auth-type bearer' + ); + } this.axiosConfigKeys = new Set([ 'adapter', 'auth', @@ -45,28 +63,82 @@ class JiraClient { 'xsrfHeaderName' ]); - // Support both token and basic auth + // Support basic, bearer, and mTLS auth const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; let auth = null; - - // If username is empty or not provided, use token-only auth - if (!config.username || config.username === '') { - headers['Authorization'] = `Bearer ${config.token}`; - } else { - // Use basic auth with username and token + + // Build auth header based on auth type + const authHeader = this.buildAuthHeader(); + if (authHeader) { + headers['Authorization'] = authHeader; + } else if (this.authType === 'basic') { + // Basic auth uses axios's `auth` option instead of a manual header auth = { username: config.username, password: config.token }; } + + // Build HTTPS agent for mTLS + const httpsAgent = this.buildHttpsAgent(); - this.clientV2 = this.createApiClient(2, { auth, headers }); - this.clientV3 = this.createApiClient(3, { auth, headers }); - this.agileClient = this.createAgileClient({ auth, headers }); + this.clientV2 = this.createApiClient(2, { auth, headers, httpsAgent }); + this.clientV3 = this.createApiClient(3, { auth, headers, httpsAgent }); + this.agileClient = this.createAgileClient({ auth, headers, httpsAgent }); + } + + buildAuthHeader() { + if (this.authType === 'mtls') { + return null; // mTLS uses client certificates, no Authorization header + } + if (this.authType === 'basic') { + return null; // Basic auth uses axios's auth option instead + } + // Bearer auth (explicit or inferred from an empty username) + if (!this.config.token) { + return null; + } + return `Bearer ${this.config.token}`; + } + + buildHttpsAgent() { + if (!this.mtls) { + return null; + } + + const options = {}; + + if (this.mtls.caCert) { + const caPath = expandHomePath(this.mtls.caCert); + if (!fs.existsSync(caPath)) { + throw new Error(`CA certificate file not found: ${this.mtls.caCert}`); + } + options.ca = fs.readFileSync(caPath); + } + if (this.mtls.clientCert) { + const certPath = expandHomePath(this.mtls.clientCert); + if (!fs.existsSync(certPath)) { + throw new Error(`Client certificate file not found: ${this.mtls.clientCert}`); + } + options.cert = fs.readFileSync(certPath); + } + if (this.mtls.clientKey) { + const keyPath = expandHomePath(this.mtls.clientKey); + if (!fs.existsSync(keyPath)) { + throw new Error(`Client key file not found: ${this.mtls.clientKey}`); + } + options.key = fs.readFileSync(keyPath); + } + + if (Object.keys(options).length === 0) { + return null; + } + + return new https.Agent(options); } // Test connection @@ -251,12 +323,16 @@ class JiraClient { return `${root}${suffix}`; } - createApiClient(version, { auth, headers }) { - const client = axios.create({ + createApiClient(version, { auth, headers, httpsAgent }) { + const clientOptions = { baseURL: this.buildApiBaseUrl(`/rest/api/${version}`), auth, headers - }); + }; + if (httpsAgent) { + clientOptions.httpsAgent = httpsAgent; + } + const client = axios.create(clientOptions); client.interceptors.response.use( response => response, error => Promise.reject(this.toJiraError(error)) @@ -264,12 +340,16 @@ class JiraClient { return client; } - createAgileClient({ auth, headers }) { - const client = axios.create({ + createAgileClient({ auth, headers, httpsAgent }) { + const clientOptions = { baseURL: this.buildApiBaseUrl('/rest/agile/1.0'), auth, headers - }); + }; + if (httpsAgent) { + clientOptions.httpsAgent = httpsAgent; + } + const client = axios.create(clientOptions); client.interceptors.response.use( response => response, error => Promise.reject(this.toJiraError(error)) @@ -300,6 +380,9 @@ class JiraClient { formatJiraErrorMessage(status, data) { if (status === 401) { + if (this.authType === 'mtls') { + return 'Authentication failed. Please verify your client certificate, client key, and CA certificate are correct and trusted by the server.'; + } const base = 'Authentication failed. Please check your credentials.'; if (this.useGateway) { return base + diff --git a/lib/utils.js b/lib/utils.js index 26dedb8..db5cc80 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,5 +1,19 @@ const chalk = require('chalk'); const Table = require('cli-table3'); +const os = require('os'); +const path = require('path'); + +// Expand a leading '~' or '~/' in a path to the user's home directory. +// Node's fs APIs do not handle tilde expansion themselves, so paths like +// '~/.certs/client.pem' read straight from config or env need this first. +function expandHomePath(p) { + if (typeof p !== 'string' || p.length === 0) return p; + if (p === '~') return os.homedir(); + if (p.startsWith('~/') || p.startsWith('~\\')) { + return path.join(os.homedir(), p.slice(2)); + } + return p; +} function convertAdfToText(node) { if (!node || typeof node === 'string') return node || ''; @@ -495,5 +509,6 @@ module.exports = { createRemoteLinksTable, displayCommentDetails, convertAdfToText, - resolveDescription + resolveDescription, + expandHomePath }; diff --git a/package-lock.json b/package-lock.json index fe1680c..decee8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pchuri/jira-cli", - "version": "2.3.2", + "version": "2.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pchuri/jira-cli", - "version": "2.3.2", + "version": "2.4.2", "license": "ISC", "dependencies": { "axios": "^1.15.0", @@ -80,6 +80,7 @@ "integrity": "sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1628,6 +1629,7 @@ "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -2496,6 +2498,7 @@ "integrity": "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.8.0" } @@ -2544,6 +2547,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2973,6 +2977,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3723,6 +3728,7 @@ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -4365,6 +4371,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7155,6 +7162,7 @@ "integrity": "sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -9869,6 +9877,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11249,6 +11258,7 @@ "integrity": "sha512-kz76azHrT8+VEkQjoCBHE06JNQgTgsC4bT8XfCzb7DHcsk9vG3fqeMVik8h5rcWCYi2Fd+M3bwA7BG8Z8cRwtA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^10.0.0", "@semantic-release/error": "^4.0.0", diff --git a/tests/commands/config.test.js b/tests/commands/config.test.js index a5fcceb..821ca85 100644 --- a/tests/commands/config.test.js +++ b/tests/commands/config.test.js @@ -1,4 +1,7 @@ const createConfigCommand = require('../../bin/commands/config'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); describe('ConfigCommand', () => { let mockFactory; @@ -88,6 +91,26 @@ describe('ConfigCommand', () => { const cloudIdOption = configCommand.options.find(opt => opt.long === '--cloud-id'); expect(cloudIdOption).toBeDefined(); }); + + it('should have auth-type option', () => { + const authTypeOption = configCommand.options.find(opt => opt.long === '--auth-type'); + expect(authTypeOption).toBeDefined(); + }); + + it('should have tls-client-cert option', () => { + const opt = configCommand.options.find(o => o.long === '--tls-client-cert'); + expect(opt).toBeDefined(); + }); + + it('should have tls-client-key option', () => { + const opt = configCommand.options.find(o => o.long === '--tls-client-key'); + expect(opt).toBeDefined(); + }); + + it('should have tls-ca-cert option', () => { + const opt = configCommand.options.find(o => o.long === '--tls-ca-cert'); + expect(opt).toBeDefined(); + }); }); describe('Bearer authentication support', () => { @@ -144,4 +167,86 @@ describe('ConfigCommand', () => { expect(mockConfig.set).toHaveBeenCalledWith('cloudId', 'abcd-1234'); }); }); + + describe('mTLS authentication support', () => { + let tmpDir; + let certPath; + let keyPath; + let caPath; + let exitSpy; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jira-config-cmd-mtls-')); + certPath = path.join(tmpDir, 'client.pem'); + keyPath = path.join(tmpDir, 'client.key'); + caPath = path.join(tmpDir, 'ca.pem'); + fs.writeFileSync(certPath, 'cert'); + fs.writeFileSync(keyPath, 'key'); + fs.writeFileSync(caPath, 'ca'); + exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + exitSpy.mockRestore(); + }); + + it('should set authType=mtls and store cert/key/ca paths', async () => { + mockConfig.isConfigured.mockReturnValue(false); + + await configCommand.parseAsync(['node', 'test', + '--server', 'https://jira.example.com', + '--auth-type', 'mtls', + '--tls-client-cert', certPath, + '--tls-client-key', keyPath, + '--tls-ca-cert', caPath + ]); + + expect(mockConfig.set).toHaveBeenCalledWith('authType', 'mtls'); + expect(mockConfig.set).toHaveBeenCalledWith('tlsClientCert', certPath); + expect(mockConfig.set).toHaveBeenCalledWith('tlsClientKey', keyPath); + expect(mockConfig.set).toHaveBeenCalledWith('tlsCaCert', caPath); + }); + + it('should reject invalid --auth-type values', async () => { + mockConfig.isConfigured.mockReturnValue(false); + + await configCommand.parseAsync(['node', 'test', + '--auth-type', 'invalid' + ]); + + expect(mockIOStreams.error).toHaveBeenCalledWith( + expect.stringContaining('--auth-type must be') + ); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockConfig.set).not.toHaveBeenCalledWith('authType', expect.anything()); + }); + + it('should error when --tls-client-cert points to a missing file', async () => { + mockConfig.isConfigured.mockReturnValue(false); + + await configCommand.parseAsync(['node', 'test', + '--tls-client-cert', '/definitely/does/not/exist/cert.pem' + ]); + + expect(mockIOStreams.error).toHaveBeenCalledWith( + expect.stringContaining('Client certificate file not found') + ); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mockConfig.set).not.toHaveBeenCalledWith('tlsClientCert', expect.anything()); + }); + + it('should error when --tls-client-key points to a missing file', async () => { + mockConfig.isConfigured.mockReturnValue(false); + + await configCommand.parseAsync(['node', 'test', + '--tls-client-key', '/definitely/does/not/exist/client.key' + ]); + + expect(mockIOStreams.error).toHaveBeenCalledWith( + expect.stringContaining('Client key file not found') + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/tests/config.test.js b/tests/config.test.js index d6d4daa..e2892ab 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -1,4 +1,7 @@ const Config = require('../lib/config'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); describe('Config', () => { let config; @@ -12,6 +15,10 @@ describe('Config', () => { delete process.env.JIRA_USERNAME; delete process.env.JIRA_API_TOKEN; delete process.env.JIRA_CLOUD_ID; + delete process.env.JIRA_AUTH_TYPE; + delete process.env.JIRA_TLS_CLIENT_CERT; + delete process.env.JIRA_TLS_CLIENT_KEY; + delete process.env.JIRA_TLS_CA_CERT; }); describe('constructor', () => { @@ -204,6 +211,20 @@ describe('Config', () => { expect(bearerConfig.username).toBe(''); expect(config.isConfigured()).toBe(true); }); + + it('should infer basic auth for legacy config without explicit authType', () => { + // Legacy stored config predating the --auth-type flag: only server, + // username, and token. getRequiredConfig() must still infer basic auth + // instead of defaulting to bearer. + config.set('server', 'https://test.atlassian.net'); + config.set('username', 'testuser'); + config.set('token', 'testtoken'); + + const requiredConfig = config.getRequiredConfig(); + expect(requiredConfig.authType).toBe('basic'); + expect(requiredConfig.username).toBe('testuser'); + expect(requiredConfig.token).toBe('testtoken'); + }); }); describe('scoped API token (cloudId) support', () => { @@ -244,5 +265,381 @@ describe('Config', () => { expect(requiredConfig.cloudId).toBe('env-cloud-id'); expect(requiredConfig.server).toBe('https://test.atlassian.net'); }); + + it('should include cloudId in the explicit basic-auth getRequiredConfig output', () => { + // The explicit-basic-auth branch was previously dropping cloudId, which + // meant scoped tokens would silently route around the gateway whenever + // the user pinned authType=basic. This test guards that integration. + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'basic'); + config.set('username', 'test@example.com'); + config.set('token', 'scoped-token'); + config.set('cloudId', 'abcd-1234'); + + const requiredConfig = config.getRequiredConfig(); + expect(requiredConfig.authType).toBe('basic'); + expect(requiredConfig.cloudId).toBe('abcd-1234'); + expect(requiredConfig.username).toBe('test@example.com'); + expect(requiredConfig.token).toBe('scoped-token'); + }); + }); + + describe('mTLS authentication support', () => { + let tmpDir; + let certPath; + let keyPath; + let caPath; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jira-config-mtls-')); + certPath = path.join(tmpDir, 'client.pem'); + keyPath = path.join(tmpDir, 'client.key'); + caPath = path.join(tmpDir, 'ca.pem'); + fs.writeFileSync(certPath, 'client-cert'); + fs.writeFileSync(keyPath, 'client-key'); + fs.writeFileSync(caPath, 'ca-cert'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should report as configured with mTLS environment variables', () => { + process.env.JIRA_HOST = 'https://jira.example.com'; + process.env.JIRA_AUTH_TYPE = 'mtls'; + process.env.JIRA_TLS_CLIENT_CERT = certPath; + process.env.JIRA_TLS_CLIENT_KEY = keyPath; + + expect(config.isConfigured()).toBe(true); + }); + + it('should get mTLS config from environment variables', () => { + process.env.JIRA_HOST = 'https://jira.example.com'; + process.env.JIRA_AUTH_TYPE = 'mtls'; + process.env.JIRA_TLS_CLIENT_CERT = certPath; + process.env.JIRA_TLS_CLIENT_KEY = keyPath; + process.env.JIRA_TLS_CA_CERT = caPath; + + const mtlsConfig = config.getRequiredConfig(); + expect(mtlsConfig.server).toBe('https://jira.example.com'); + expect(mtlsConfig.authType).toBe('mtls'); + expect(mtlsConfig.mtls.clientCert).toBe(certPath); + expect(mtlsConfig.mtls.clientKey).toBe(keyPath); + expect(mtlsConfig.mtls.caCert).toBe(caPath); + expect(mtlsConfig.token).toBeUndefined(); + }); + + it('should report as configured with mTLS stored config', () => { + config.set('server', 'https://jira.example.com'); + config.set('authType', 'mtls'); + config.set('tlsClientCert', certPath); + config.set('tlsClientKey', keyPath); + + expect(config.isConfigured()).toBe(true); + }); + + it('should get mTLS config from stored config', () => { + config.set('server', 'https://jira.example.com'); + config.set('authType', 'mtls'); + config.set('tlsClientCert', certPath); + config.set('tlsClientKey', keyPath); + config.set('tlsCaCert', caPath); + + const mtlsConfig = config.getRequiredConfig(); + expect(mtlsConfig.server).toBe('https://jira.example.com'); + expect(mtlsConfig.authType).toBe('mtls'); + expect(mtlsConfig.mtls.clientCert).toBe(certPath); + expect(mtlsConfig.mtls.clientKey).toBe(keyPath); + expect(mtlsConfig.mtls.caCert).toBe(caPath); + }); + + it('should throw error for mTLS with missing client cert', () => { + process.env.JIRA_HOST = 'https://jira.example.com'; + process.env.JIRA_AUTH_TYPE = 'mtls'; + process.env.JIRA_TLS_CLIENT_KEY = keyPath; + + expect(() => config.getRequiredConfig()).toThrow('mTLS requires a client certificate'); + }); + + it('should throw error for mTLS with missing client key', () => { + process.env.JIRA_HOST = 'https://jira.example.com'; + process.env.JIRA_AUTH_TYPE = 'mtls'; + process.env.JIRA_TLS_CLIENT_CERT = certPath; + + expect(() => config.getRequiredConfig()).toThrow('mTLS requires a client key'); + }); + + it('should throw error for mTLS with nonexistent cert file', () => { + process.env.JIRA_HOST = 'https://jira.example.com'; + process.env.JIRA_AUTH_TYPE = 'mtls'; + process.env.JIRA_TLS_CLIENT_CERT = '/nonexistent/client.pem'; + process.env.JIRA_TLS_CLIENT_KEY = keyPath; + + expect(() => config.getRequiredConfig()).toThrow('Client certificate file not found'); + }); + + it('should validate mTLS config correctly', () => { + const validMtls = { + clientCert: certPath, + clientKey: keyPath, + caCert: caPath + }; + expect(config.validateMtlsConfig(validMtls)).toEqual([]); + + const invalidMtls = { + clientCert: '/nonexistent/cert.pem', + clientKey: keyPath + }; + const errors = config.validateMtlsConfig(invalidMtls); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]).toContain('not found'); + }); + + it('should report as not configured when authType is mtls but cert/key are missing', () => { + // Previously, isConfigured() only checked (server && token) and would + // return true for an mTLS config with a stale token, even though + // getRequiredConfig() would then throw on the missing cert. + config.set('server', 'https://jira.example.com'); + config.set('authType', 'mtls'); + config.set('token', 'stale-token'); + + expect(config.isConfigured()).toBe(false); + }); + + it('should report as configured for mTLS without a token', () => { + config.set('server', 'https://jira.example.com'); + config.set('authType', 'mtls'); + config.set('tlsClientCert', certPath); + config.set('tlsClientKey', keyPath); + + expect(config.isConfigured()).toBe(true); + }); + + it('should report as not configured when env mTLS is incomplete even if JIRA_API_TOKEN is set', () => { + // When JIRA_AUTH_TYPE=mtls is set, that choice is authoritative. + // Previously, a stale JIRA_API_TOKEN would cause isConfigured() to + // return true via the JIRA_HOST + JIRA_API_TOKEN fallback even when + // the required client cert/key env vars were missing. + process.env.JIRA_HOST = 'https://jira.example.com'; + process.env.JIRA_AUTH_TYPE = 'mtls'; + process.env.JIRA_API_TOKEN = 'stale-token'; + // Intentionally omit JIRA_TLS_CLIENT_CERT / JIRA_TLS_CLIENT_KEY + + expect(config.isConfigured()).toBe(false); + }); + + it('should report as configured for env mTLS when all required vars are set, even with a stale token', () => { + process.env.JIRA_HOST = 'https://jira.example.com'; + process.env.JIRA_AUTH_TYPE = 'mtls'; + process.env.JIRA_TLS_CLIENT_CERT = certPath; + process.env.JIRA_TLS_CLIENT_KEY = keyPath; + process.env.JIRA_API_TOKEN = 'stale-token'; + + expect(config.isConfigured()).toBe(true); + }); + + it('should report as not configured for partial env mTLS (cert only) even with a stale JIRA_API_TOKEN', () => { + // Partial mTLS env (client cert set but client key missing) must not + // fall through to the JIRA_HOST + JIRA_API_TOKEN path. + process.env.JIRA_HOST = 'https://jira.example.com'; + process.env.JIRA_AUTH_TYPE = 'mtls'; + process.env.JIRA_TLS_CLIENT_CERT = certPath; + process.env.JIRA_API_TOKEN = 'stale-token'; + // Intentionally omit JIRA_TLS_CLIENT_KEY + + expect(config.isConfigured()).toBe(false); + }); + + it('should let env mTLS override a complete stored basic-auth config', () => { + // Env selects mTLS authoritatively. Even if stored config is a fully + // valid basic-auth setup, an incomplete env mTLS selection should report + // the CLI as not configured rather than silently using the stored config. + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'basic'); + config.set('username', 'testuser'); + config.set('token', 'testtoken'); + + process.env.JIRA_HOST = 'https://jira.example.com'; + process.env.JIRA_AUTH_TYPE = 'mtls'; + // No cert/key in env + + expect(config.isConfigured()).toBe(false); + }); + + it('should report as not configured when stored mTLS cert/key are empty strings', () => { + config.set('server', 'https://jira.example.com'); + config.set('authType', 'mtls'); + config.set('tlsClientCert', ''); + config.set('tlsClientKey', ''); + + expect(config.isConfigured()).toBe(false); + }); + }); + + describe('empty-value validation', () => { + it('should report as not configured when stored basic auth has an empty username', () => { + // has() only checks key presence; an empty string previously slipped + // through. isConfigured() should now require non-empty values. + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'basic'); + config.set('username', ''); + config.set('token', 'testtoken'); + + expect(config.isConfigured()).toBe(false); + }); + + it('should report as not configured when stored basic auth has an empty token', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'basic'); + config.set('username', 'testuser'); + config.set('token', ''); + + expect(config.isConfigured()).toBe(false); + }); + + it('should throw from getRequiredConfig when explicit basic auth has an empty username', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'basic'); + config.set('username', ''); + config.set('token', 'testtoken'); + + expect(() => config.getRequiredConfig()).toThrow(/Missing: username/); + }); + + it('should throw from getRequiredConfig when explicit basic auth has an empty token', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'basic'); + config.set('username', 'testuser'); + config.set('token', ''); + + expect(() => config.getRequiredConfig()).toThrow(/Missing: token/); + }); + + it('should report as not configured when stored bearer auth has an empty token', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'bearer'); + config.set('token', ''); + + expect(config.isConfigured()).toBe(false); + }); + + it('should report as not configured when legacy stored config has an empty token', () => { + // Legacy config (no explicit authType) with an empty token should not + // pass isConfigured() either. + config.set('server', 'https://test.atlassian.net'); + config.set('token', ''); + + expect(config.isConfigured()).toBe(false); + }); + + it('should report as not configured when stored bearer auth has a whitespace-only token', () => { + // End-to-end check that the hasNonEmpty helper is actually wired into + // isConfigured() for the bearer branch - a future change that skips the + // helper would be caught here. + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'bearer'); + config.set('token', ' '); + + expect(config.isConfigured()).toBe(false); + }); + + it('should report as not configured for legacy stored config with username set but empty token', () => { + // Legacy config (no explicit authType) that looks like basic auth but + // has an empty token must not slip through the final `hasNonEmpty('token')` + // check at the end of isConfigured(). + config.set('server', 'https://test.atlassian.net'); + config.set('username', 'testuser'); + config.set('token', ''); + + expect(config.isConfigured()).toBe(false); + }); + + it('should expose a hasNonEmpty helper that rejects empty and whitespace-only strings', () => { + config.set('username', ''); + expect(config.hasNonEmpty('username')).toBe(false); + + config.set('username', ' '); + expect(config.hasNonEmpty('username')).toBe(false); + + config.set('username', 'testuser'); + expect(config.hasNonEmpty('username')).toBe(true); + + expect(config.hasNonEmpty('nonexistent')).toBe(false); + }); + }); + + describe('explicit basic auth validation', () => { + it('should report as not configured when authType is basic but username is missing', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'basic'); + config.set('token', 'testtoken'); + + expect(config.isConfigured()).toBe(false); + }); + + it('should report as not configured when authType is basic but token is missing', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'basic'); + config.set('username', 'testuser'); + + expect(config.isConfigured()).toBe(false); + }); + + it('should throw from getRequiredConfig when explicit basic auth has no username', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'basic'); + config.set('token', 'testtoken'); + + expect(() => config.getRequiredConfig()).toThrow(/Basic auth/); + expect(() => config.getRequiredConfig()).toThrow(/Missing: username/); + }); + + it('should throw from getRequiredConfig when explicit basic auth has no token', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'basic'); + config.set('username', 'testuser'); + + expect(() => config.getRequiredConfig()).toThrow(/Basic auth/); + expect(() => config.getRequiredConfig()).toThrow(/Missing: token/); + }); + + it('should return basic auth config when all fields are present', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'basic'); + config.set('username', 'testuser'); + config.set('token', 'testtoken'); + + const requiredConfig = config.getRequiredConfig(); + expect(requiredConfig.authType).toBe('basic'); + expect(requiredConfig.username).toBe('testuser'); + expect(requiredConfig.token).toBe('testtoken'); + }); + }); + + describe('explicit bearer auth', () => { + it('should report as configured with explicit bearer authType, server, and token', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'bearer'); + config.set('token', 'testtoken'); + + expect(config.isConfigured()).toBe(true); + }); + + it('should report as not configured with explicit bearer authType but no token', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'bearer'); + + expect(config.isConfigured()).toBe(false); + }); + + it('should return bearer auth config from getRequiredConfig', () => { + config.set('server', 'https://test.atlassian.net'); + config.set('authType', 'bearer'); + config.set('token', 'testtoken'); + + const requiredConfig = config.getRequiredConfig(); + expect(requiredConfig.authType).toBe('bearer'); + expect(requiredConfig.token).toBe('testtoken'); + }); }); }); diff --git a/tests/jira-client.test.js b/tests/jira-client.test.js index f272b91..42d2a43 100644 --- a/tests/jira-client.test.js +++ b/tests/jira-client.test.js @@ -139,6 +139,45 @@ describe('JiraClient', () => { password: 'scoped-token' }); }); + + test('should throw when explicit basic auth is used with an empty username', () => { + // Previously an explicit authType=basic with no username silently fell + // back to Bearer auth, masking misconfiguration. + const badConfig = { + server: 'https://test.atlassian.net', + authType: 'basic', + username: '', + token: 'test-token' + }; + + expect(() => new JiraClient(badConfig)).toThrow(/Basic auth/); + expect(() => new JiraClient(badConfig)).toThrow(/username/i); + }); + + test('should throw when explicit basic auth is used without a username property', () => { + const badConfig = { + server: 'https://test.atlassian.net', + authType: 'basic', + token: 'test-token' + }; + + expect(() => new JiraClient(badConfig)).toThrow(/Basic auth/); + }); + + test('should still use Bearer auth for legacy config with empty username and no explicit authType', () => { + // Confirms the fail-fast only fires for *explicit* basic auth; legacy + // configs without an authType keep their previous inference behavior. + const legacyConfig = { + server: 'https://test.atlassian.net', + username: '', + token: 'test-token' + }; + + const legacyClient = new JiraClient(legacyConfig); + + expect(legacyClient.authType).toBe('bearer'); + expect(legacyClient.clientV2.defaults.headers['Authorization']).toBe('Bearer test-token'); + }); }); describe('API methods', () => { @@ -378,4 +417,127 @@ describe('JiraClient', () => { await expect(client.getIssue('TEST-1')).rejects.toThrow('Network error'); }); }); + + // mTLS authentication tests + describe('mTLS authentication', () => { + const fs = require('fs'); + const path = require('path'); + const os = require('os'); + + let tmpDir; + let certPath; + let keyPath; + let caPath; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jira-mtls-')); + certPath = path.join(tmpDir, 'client.pem'); + keyPath = path.join(tmpDir, 'client.key'); + caPath = path.join(tmpDir, 'ca.pem'); + fs.writeFileSync(certPath, 'client-cert'); + fs.writeFileSync(keyPath, 'client-key'); + fs.writeFileSync(caPath, 'ca-cert'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('should create client with mTLS auth without Authorization header', () => { + const mtlsConfig = { + server: 'https://jira.example.com', + authType: 'mtls', + mtls: { + caCert: caPath, + clientCert: certPath, + clientKey: keyPath + } + }; + + const mtlsClient = new JiraClient(mtlsConfig); + + expect(mtlsClient.authType).toBe('mtls'); + expect(mtlsClient.clientV2.defaults.headers['Authorization']).toBeUndefined(); + expect(mtlsClient.clientV3.defaults.headers['Authorization']).toBeUndefined(); + expect(mtlsClient.clientV2.defaults.httpsAgent).toBeDefined(); + expect(mtlsClient.clientV3.defaults.httpsAgent).toBeDefined(); + }); + + test('should configure httpsAgent with certificate files', () => { + const mtlsConfig = { + server: 'https://jira.example.com', + authType: 'mtls', + mtls: { + caCert: caPath, + clientCert: certPath, + clientKey: keyPath + } + }; + + const mtlsClient = new JiraClient(mtlsConfig); + + expect(mtlsClient.clientV2.defaults.httpsAgent.options.ca.toString()).toBe('ca-cert'); + expect(mtlsClient.clientV2.defaults.httpsAgent.options.cert.toString()).toBe('client-cert'); + expect(mtlsClient.clientV2.defaults.httpsAgent.options.key.toString()).toBe('client-key'); + }); + + test('should work without CA certificate (optional)', () => { + const mtlsConfig = { + server: 'https://jira.example.com', + authType: 'mtls', + mtls: { + clientCert: certPath, + clientKey: keyPath + } + }; + + const mtlsClient = new JiraClient(mtlsConfig); + + expect(mtlsClient.clientV2.defaults.httpsAgent.options.cert.toString()).toBe('client-cert'); + expect(mtlsClient.clientV2.defaults.httpsAgent.options.key.toString()).toBe('client-key'); + expect(mtlsClient.clientV2.defaults.httpsAgent.options.ca).toBeUndefined(); + }); + + test('should throw error for missing client certificate file', () => { + const mtlsConfig = { + server: 'https://jira.example.com', + authType: 'mtls', + mtls: { + clientCert: '/nonexistent/client.pem', + clientKey: keyPath + } + }; + + expect(() => new JiraClient(mtlsConfig)).toThrow('Client certificate file not found'); + }); + + test('should throw error for missing client key file', () => { + const mtlsConfig = { + server: 'https://jira.example.com', + authType: 'mtls', + mtls: { + clientCert: certPath, + clientKey: '/nonexistent/client.key' + } + }; + + expect(() => new JiraClient(mtlsConfig)).toThrow('Client key file not found'); + }); + + test('should provide certificate hints for 401 errors with mTLS', () => { + const mtlsConfig = { + server: 'https://jira.example.com', + authType: 'mtls', + mtls: { + clientCert: certPath, + clientKey: keyPath + } + }; + + const mtlsClient = new JiraClient(mtlsConfig); + const errorMessage = mtlsClient.formatJiraErrorMessage(401, {}); + + expect(errorMessage).toContain('client certificate'); + }); + }); }); diff --git a/tests/utils.test.js b/tests/utils.test.js index 5891e5e..9426f74 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -555,4 +555,33 @@ describe('Utils', () => { expect(Utils.escapeJqlString('TEST-123')).toBe('TEST-123'); }); }); + + describe('expandHomePath', () => { + const os = require('os'); + const path = require('path'); + + it('should expand a leading ~/ to the home directory', () => { + const expanded = Utils.expandHomePath('~/.certs/client.pem'); + expect(expanded).toBe(path.join(os.homedir(), '.certs/client.pem')); + }); + + it('should expand a bare ~ to the home directory', () => { + expect(Utils.expandHomePath('~')).toBe(os.homedir()); + }); + + it('should leave absolute and relative paths unchanged', () => { + expect(Utils.expandHomePath('/etc/ssl/cert.pem')).toBe('/etc/ssl/cert.pem'); + expect(Utils.expandHomePath('./certs/client.pem')).toBe('./certs/client.pem'); + }); + + it('should not expand ~ that does not start the path', () => { + expect(Utils.expandHomePath('/tmp/~weird')).toBe('/tmp/~weird'); + }); + + it('should pass through non-string and empty values', () => { + expect(Utils.expandHomePath('')).toBe(''); + expect(Utils.expandHomePath(undefined)).toBe(undefined); + expect(Utils.expandHomePath(null)).toBe(null); + }); + }); });