Skip to content
Merged
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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -176,7 +184,7 @@ confluence init --email "user@example.com" --token "your-api-token"
**Available flags:**
- `-d, --domain <domain>` - Confluence domain (e.g., `company.atlassian.net`)
- `-p, --api-path <path>` - REST API path (e.g., `/wiki/rest/api`)
- `-a, --auth-type <type>` - Authentication type: `basic`, `bearer`, `mtls`, or `cookie`
- `-a, --auth-type <type>` - Authentication type: `basic`, `bearer`, `mtls`, `cookie`, or `none`
- `-e, --email <email>` - Email or username for basic authentication
- `-t, --token <token>` - API token or password
- `-c, --cookie <cookie>` - Cookie for Enterprise SSO authentication (e.g., `"JSESSIONID=..."`)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion bin/confluence.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ program
.option('-d, --domain <domain>', 'Confluence domain')
.option('--protocol <protocol>', 'Protocol (http or https)')
.option('-p, --api-path <path>', 'REST API path')
.option('-a, --auth-type <type>', 'Authentication type (basic, bearer, mtls, or cookie)')
.option('-a, --auth-type <type>', 'Authentication type (basic, bearer, mtls, cookie, or none)')
.option('-e, --email <email>', 'Email or username for basic auth')
.option('-t, --token <token>', 'API token')
.option('-c, --cookie <cookie>', 'Cookie for Enterprise SSO authentication (e.g., "JSESSIONID=...")')
Expand Down
26 changes: 19 additions & 7 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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.');
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
});
Expand Down Expand Up @@ -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')
},
{
Expand Down Expand Up @@ -639,6 +644,7 @@ async function initConfig(cliOptions = {}) {
providedValues.domain &&
(
providedValues.authType === 'mtls'
|| providedValues.authType === 'none'
|| (providedValues.authType === 'cookie' && providedValues.cookie)
|| (
providedValues.token &&
Expand All @@ -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);
}
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion lib/confluence-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down Expand Up @@ -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;
}

Expand Down
21 changes: 21 additions & 0 deletions tests/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
37 changes: 37 additions & 0 deletions tests/confluence-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand All @@ -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({
Expand Down