From f8ccba9b2f4488bcfdf76e30620b259edd891e56 Mon Sep 17 00:00:00 2001 From: Nathan Rignall Date: Wed, 20 May 2026 17:04:58 +0300 Subject: [PATCH] feat(auth): add 'none' auth type for reverse-proxy injected credentials Adds a new --auth-type/CONFLUENCE_AUTH_TYPE value 'none' that builds a client sending no Authorization or Cookie header, for environments where a local reverse proxy injects authentication on the wire. --- README.md | 19 ++++++++++++++++- bin/confluence.js | 2 +- lib/config.js | 26 ++++++++++++++++------- lib/confluence-client.js | 7 ++++++- tests/config.test.js | 21 +++++++++++++++++++ tests/confluence-client.test.js | 37 +++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 835c9b5..706d3c4 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,14 @@ confluence --profile sso init \ --cookie "JSESSIONID=abc123; XSRF-TOKEN=xyz789" ``` +**Reverse-proxy / no-auth profile** (credentials injected upstream): +```bash +confluence --profile proxy init \ + --domain "confluence.internal" \ + --api-path "/rest/api" \ + --auth-type "none" +``` + **Hybrid mode** (some fields provided, rest via prompts): ```bash # Domain and token provided, will prompt for auth method and email @@ -176,7 +184,7 @@ confluence init --email "user@example.com" --token "your-api-token" **Available flags:** - `-d, --domain ` - Confluence domain (e.g., `company.atlassian.net`) - `-p, --api-path ` - REST API path (e.g., `/wiki/rest/api`) -- `-a, --auth-type ` - Authentication type: `basic`, `bearer`, `mtls`, or `cookie` +- `-a, --auth-type ` - Authentication type: `basic`, `bearer`, `mtls`, `cookie`, or `none` - `-e, --email ` - Email or username for basic authentication - `-t, --token ` - API token or password - `-c, --cookie ` - Cookie for Enterprise SSO authentication (e.g., `"JSESSIONID=..."`) @@ -217,6 +225,13 @@ export CONFLUENCE_AUTH_TYPE="cookie" export CONFLUENCE_COOKIE="JSESSIONID=abc123xyz..." ``` +**Reverse-proxy / no-auth environment variables**: +```bash +export CONFLUENCE_DOMAIN="confluence.internal" +export CONFLUENCE_API_PATH="/rest/api" +export CONFLUENCE_AUTH_TYPE="none" +``` + **Scoped API token** (recommended for agents): ```bash export CONFLUENCE_DOMAIN="api.atlassian.com" @@ -321,6 +336,8 @@ For **read-only** usage, select at minimum: `read:confluence-content.all`, `read **Enterprise SSO with Cookie Authentication:** For Confluence instances behind Enterprise SSO (SAML, OAuth, Okta, etc.) where API tokens or Basic/Bearer auth are not available, you can authenticate using session cookies. After logging in through your browser, extract the session cookie (typically `JSESSIONID` or similar) from your browser's dev tools and configure it via the `--cookie` flag or `CONFLUENCE_COOKIE` environment variable. The cookie is sent in the `Cookie` header instead of an `Authorization` header. Note that session cookies typically expire, so you'll need to refresh them periodically. For security, prefer `CONFLUENCE_COOKIE` env var or interactive prompt over `--cookie` flag since command-line arguments may be visible in shell history and process listings. +**Reverse-proxy injected authentication:** For deployments where a local reverse proxy injects credentials on the wire (e.g. SPNEGO/Kerberos, mTLS terminated at the proxy edge, or header injection), set `authType=none`. In this mode the CLI sends no `Authorization` or `Cookie` header — authentication is entirely the proxy's responsibility. Point `CONFLUENCE_DOMAIN` at the proxy and ensure no credentials are configured on the CLI side. + ## Usage ### Read a Page diff --git a/bin/confluence.js b/bin/confluence.js index 782a3ca..ee61ba9 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -114,7 +114,7 @@ program .option('-d, --domain ', 'Confluence domain') .option('--protocol ', 'Protocol (http or https)') .option('-p, --api-path ', 'REST API path') - .option('-a, --auth-type ', 'Authentication type (basic, bearer, mtls, or cookie)') + .option('-a, --auth-type ', 'Authentication type (basic, bearer, mtls, cookie, or none)') .option('-e, --email ', 'Email or username for basic auth') .option('-t, --token ', 'API token') .option('-c, --cookie ', 'Cookie for Enterprise SSO authentication (e.g., "JSESSIONID=...")') diff --git a/lib/config.js b/lib/config.js index 23dfcf9..54e5389 100644 --- a/lib/config.js +++ b/lib/config.js @@ -12,10 +12,11 @@ const AUTH_CHOICES = [ { name: 'Basic (credentials)', value: 'basic' }, { name: 'Bearer token', value: 'bearer' }, { name: 'Client certificate (mTLS)', value: 'mtls' }, - { name: 'Cookie (Enterprise SSO)', value: 'cookie' } + { name: 'Cookie (Enterprise SSO)', value: 'cookie' }, + { name: 'None (auth injected by reverse proxy)', value: 'none' } ]; -const AUTH_TYPES = ['basic', 'bearer', 'mtls', 'cookie']; +const AUTH_TYPES = ['basic', 'bearer', 'mtls', 'cookie', 'none']; const { VALID_LINK_STYLES } = require('./macro-converter'); @@ -125,6 +126,10 @@ const validateMtlsProtocol = (protocol) => { const validateAuthConfig = (auth, mtlsSourceLabel) => { const errors = []; + if (auth.authType === 'none') { + return errors; + } + if (auth.authType === 'basic' && !auth.email) { errors.push('Basic authentication requires an email address or username.'); } @@ -287,7 +292,7 @@ const validateCliOptions = (options) => { } if (options.authType && (typeof options.authType !== 'string' || !AUTH_TYPES.includes(options.authType.toLowerCase()))) { - errors.push('--auth-type must be "basic", "bearer", "mtls", or "cookie"'); + errors.push('--auth-type must be "basic", "bearer", "mtls", "cookie", or "none"'); } // Check if basic auth is provided with email @@ -451,7 +456,7 @@ const promptForMissingValues = async (providedValues) => { message: 'API token / password:', when: (responses) => { const authType = providedValues.authType || responses.authType; - return authType !== 'mtls' && authType !== 'cookie'; + return authType !== 'mtls' && authType !== 'cookie' && authType !== 'none'; }, validate: requiredInput('API token / password') }); @@ -591,7 +596,7 @@ async function initConfig(cliOptions = {}) { type: 'password', name: 'token', message: 'API token / password:', - when: (responses) => responses.authType !== 'mtls' && responses.authType !== 'cookie', + when: (responses) => responses.authType !== 'mtls' && responses.authType !== 'cookie' && responses.authType !== 'none', validate: requiredInput('API token / password') }, { @@ -639,6 +644,7 @@ async function initConfig(cliOptions = {}) { providedValues.domain && ( providedValues.authType === 'mtls' + || providedValues.authType === 'none' || (providedValues.authType === 'cookie' && providedValues.cookie) || ( providedValues.token && @@ -665,7 +671,12 @@ async function initConfig(cliOptions = {}) { process.exit(1); } - if (normalizedAuthType !== 'mtls' && normalizedAuthType !== 'cookie' && !providedValues.token) { + if ( + normalizedAuthType !== 'mtls' + && normalizedAuthType !== 'cookie' + && normalizedAuthType !== 'none' + && !providedValues.token + ) { console.error(chalk.red('❌ Token is required for basic or bearer authentication')); process.exit(1); } @@ -752,7 +763,8 @@ function getConfig(profileName) { const hasEnvAuth = envToken || envAuthType === 'mtls' || envMtls - || envAuthType === 'cookie' || envCookie; + || envAuthType === 'cookie' || envCookie + || envAuthType === 'none'; if (envDomain && hasEnvAuth) { const inferredAuthType = envAuthType diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 7668f12..64ab17e 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -98,6 +98,11 @@ class ConfluenceClient { 'Please verify your cookie is valid and not expired.', 'You may need to re-authenticate through your Enterprise SSO to get a fresh cookie.' ); + } else if (this.authType === 'none') { + hints.push( + 'No credentials are sent by the CLI in this mode — auth is expected from a reverse proxy.', + 'Verify the proxy is reachable and is injecting a valid Authorization header.' + ); } else { hints.push( 'Please verify your personal access token is valid and not expired.' @@ -142,7 +147,7 @@ class ConfluenceClient { } buildAuthHeader() { - if (this.authType === 'mtls' || this.authType === 'cookie') { + if (this.authType === 'mtls' || this.authType === 'cookie' || this.authType === 'none') { return null; } diff --git a/tests/config.test.js b/tests/config.test.js index 21c5a44..6dc1f77 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -281,6 +281,27 @@ describe('getConfig env var aliases', () => { logSpy.mockRestore(); } }); + + test('CONFLUENCE_AUTH_TYPE=none with just a domain resolves with no credentials', () => { + process.env.CONFLUENCE_DOMAIN = 'confluence.internal'; + process.env.CONFLUENCE_AUTH_TYPE = 'none'; + + const config = getConfig(); + expect(config.authType).toBe('none'); + expect(config.domain).toBe('confluence.internal'); + expect(config.token).toBeUndefined(); + expect(config.email).toBeUndefined(); + expect(config.cookie).toBeUndefined(); + expect(config.mtls).toBeUndefined(); + }); + + test('CONFLUENCE_AUTH_TYPE=NONE (uppercase) normalizes to none', () => { + process.env.CONFLUENCE_DOMAIN = 'confluence.internal'; + process.env.CONFLUENCE_AUTH_TYPE = 'NONE'; + + const config = getConfig(); + expect(config.authType).toBe('none'); + }); }); describe('initConfig CLI option validation', () => { diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index d7565cb..d7180f6 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -323,6 +323,28 @@ describe('ConfluenceClient', () => { expect(cookieClient.client.defaults.headers.Cookie).toBe('JSESSIONID=abc; XSRF-TOKEN=xyz'); }); + + test('sends no Authorization or Cookie header when authType is none', () => { + const noneClient = new ConfluenceClient({ + domain: 'confluence.internal', + authType: 'none', + apiPath: '/rest/api' + }); + + expect(noneClient.authType).toBe('none'); + expect(noneClient.client.defaults.headers.Authorization).toBeUndefined(); + expect(noneClient.client.defaults.headers.Cookie).toBeUndefined(); + }); + + test('buildAuthHeader returns null for none auth', () => { + const noneClient = new ConfluenceClient({ + domain: 'confluence.internal', + authType: 'none' + }); + + expect(noneClient.buildAuthHeader()).toBeNull(); + expect(noneClient.buildAuthHeaders()).toEqual({}); + }); }); describe('401 error handling (cookie auth)', () => { @@ -342,6 +364,21 @@ describe('ConfluenceClient', () => { }); }); + describe('401 error handling (none auth)', () => { + test('provides reverse-proxy hint for none auth', async () => { + const noneClient = new ConfluenceClient({ + domain: 'confluence.internal', + authType: 'none', + apiPath: '/rest/api' + }); + const mock = new MockAdapter(noneClient.client); + mock.onGet(/\/content\/123/).reply(401); + + await expect(noneClient.readPage('123')).rejects.toThrow(/reverse proxy/); + mock.restore(); + }); + }); + describe('401 error handling', () => { test('provides scoped token hints when using api.atlassian.com', async () => { const scopedClient = new ConfluenceClient({