feat(Confluence Node): Add Confluence Cloud OAuth2 credential (#35732)

This commit is contained in:
Ilfat Mindubaev 2026-08-13 11:54:01 +00:00 committed by GitHub
parent 5e2779e0be
commit f9fd8a83e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1120 additions and 498 deletions

View File

@ -106,6 +106,7 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [
'oAuth2Api',
'googleOAuth2Api',
'microsoftOAuth2Api',
'atlassianOAuth2Api',
'highLevelOAuth2Api',
'mcpOAuth2Api',
'snowflakeOAuth2Api',

View File

@ -181,7 +181,13 @@ const validateCredential = async (credentialId: string, credentialTypeName: stri
// Check if this is an OAuth credential type
const credentialType = credentialsStore.getCredentialTypeByName(credentialTypeName);
const isOAuth = credentialType?.extends?.some((ext) =>
['oAuth2Api', 'oAuth1Api', 'googleOAuth2Api', 'microsoftOAuth2Api'].includes(ext),
[
'oAuth2Api',
'oAuth1Api',
'googleOAuth2Api',
'microsoftOAuth2Api',
'atlassianOAuth2Api',
].includes(ext),
);
let isValid = false;

View File

@ -390,6 +390,33 @@ describe('useAppCredentials', () => {
expect(appEntries.value[0].supportsInstantOAuth).toBe(false);
});
it('should prioritize a vendor-base OAuth2 child over API key credentials', () => {
const oAuth2Cred = createMockCredentialType({
name: 'jiraSoftwareCloudOAuth2Api',
displayName: 'Jira SW Cloud OAuth2',
extends: ['atlassianOAuth2Api'],
});
const apiKeyCred = createMockCredentialType({
name: 'jiraSoftwareCloudApi',
displayName: 'Jira SW Cloud API',
});
credentialsStore.allCredentialTypes = [apiKeyCred, oAuth2Cred];
nodeTypesStore.visibleNodeTypes = [
createMockNodeType({
name: 'n8n-nodes-base.jira',
displayName: 'Jira Software',
credentials: [{ name: 'jiraSoftwareCloudApi' }, { name: 'jiraSoftwareCloudOAuth2Api' }],
}),
];
const { appEntries } = useAppCredentials();
expect(appEntries.value).toHaveLength(1);
expect(appEntries.value[0].credentialType?.name).toBe('jiraSoftwareCloudOAuth2Api');
});
it('should mark supportsInstantOAuth as false for API key credentials', () => {
const apiKeyCred = createMockCredentialType({
name: 'stripeApi',

View File

@ -164,16 +164,22 @@ export function useAppCredentials() {
// Ensure community node previews are fetched
void nodeTypesStore.fetchCommunityNodePreviews();
const isOAuthCredentialType = (credType: ICredentialType): boolean => {
const isOAuth2CredentialType = (credType: ICredentialType): boolean => {
if (!credType.extends) return false;
return (
credType.extends.includes('oAuth2Api') ||
credType.extends.includes('oAuth1Api') ||
credType.extends.includes('googleOAuth2Api') ||
credType.extends.includes('microsoftOAuth2Api')
credType.extends.includes('microsoftOAuth2Api') ||
credType.extends.includes('atlassianOAuth2Api')
);
};
const isOAuth1CredentialType = (credType: ICredentialType): boolean =>
credType.extends?.includes('oAuth1Api') ?? false;
const isOAuthCredentialType = (credType: ICredentialType): boolean =>
isOAuth2CredentialType(credType) || isOAuth1CredentialType(credType);
const isGoogleOAuthType = (credType: ICredentialType): boolean => {
if (!credType.extends) return false;
return credType.extends.includes('googleOAuth2Api');
@ -211,8 +217,8 @@ export function useAppCredentials() {
const getCredentialPriority = (credType: ICredentialType): number => {
const isGoogle = isGoogleOAuthType(credType);
const isMicrosoft = isMicrosoftOAuthType(credType);
const isOAuth2 = credType.extends?.includes('oAuth2Api');
const isOAuth1 = credType.extends?.includes('oAuth1Api');
const isOAuth2 = isOAuth2CredentialType(credType);
const isOAuth1 = isOAuth1CredentialType(credType);
const hasInstant = hasInstantOAuth(credType);
// Google/Microsoft OAuth with native sign-in buttons are highest priority

View File

@ -0,0 +1,64 @@
import type { Icon, ICredentialType, INodeProperties } from 'n8n-workflow';
export class AtlassianOAuth2Api implements ICredentialType {
name = 'atlassianOAuth2Api';
extends = ['oAuth2Api'];
icon: Icon = 'file:icons/Atlassian.svg';
displayName = 'Atlassian OAuth2 API';
documentationUrl = 'atlassian';
properties: INodeProperties[] = [
{
// Named `domain` so existing Jira OAuth2 credentials, which store the value
// under this key, keep working.
displayName: 'Site URL',
name: 'domain',
type: 'string',
default: '',
placeholder: 'https://your-site.atlassian.net',
required: true,
description:
'The URL of your Atlassian site, e.g. https://your-site.atlassian.net. The scheme and any path (like /wiki) are ignored.',
},
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'authorizationCode',
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden',
default: 'https://auth.atlassian.com/authorize',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: 'https://auth.atlassian.com/oauth/token',
required: true,
},
{
// `prompt=consent` forces the Atlassian consent screen (which shows the
// active account and offers a switch) instead of a silent re-auth.
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default: 'audience=api.atlassian.com&prompt=consent',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden',
default: 'header',
},
// The `scope` field inherited from `oAuth2Api` stays visible and user-defined;
// product credentials override it with their own defaults.
];
}

View File

@ -0,0 +1,56 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
import { confluenceDefaultScopes } from './common/atlassian-scopes';
const defaultScopes = confluenceDefaultScopes;
export class ConfluenceCloudOAuth2Api implements ICredentialType {
name = 'confluenceCloudOAuth2Api';
extends = ['atlassianOAuth2Api'];
displayName = 'Confluence Cloud OAuth2 API';
documentationUrl = 'confluence';
properties: INodeProperties[] = [
{
displayName: 'Custom Scopes',
name: 'customScopes',
type: 'boolean',
default: false,
description: 'Define custom scopes',
},
{
displayName:
'The default scopes needed for the node to work are already set. If you change these the node may not function correctly.',
name: 'customScopesNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
customScopes: [true],
},
},
},
{
displayName: 'Enabled Scopes',
name: 'enabledScopes',
type: 'string',
displayOptions: {
show: {
customScopes: [true],
},
},
default: defaultScopes.join(' '),
description: 'Scopes that should be enabled',
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default:
'={{$self["customScopes"] ? $self["enabledScopes"] : "' + defaultScopes.join(' ') + '"}}',
},
];
}

View File

@ -1,64 +1,19 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
const defaultScopes = [
'read:jira-user',
'read:jira-work',
'write:jira-work',
'manage:jira-webhook',
'manage:jira-user',
'offline_access',
];
import { jiraDefaultScopes } from './common/atlassian-scopes';
const defaultScopes = jiraDefaultScopes;
export class JiraSoftwareCloudOAuth2Api implements ICredentialType {
name = 'jiraSoftwareCloudOAuth2Api';
extends = ['oAuth2Api'];
extends = ['atlassianOAuth2Api'];
displayName = 'Jira SW Cloud OAuth2 API';
documentationUrl = 'jira';
properties: INodeProperties[] = [
{
displayName: 'Domain',
name: 'domain',
type: 'string',
default: '',
placeholder: 'https://example.atlassian.net',
required: true,
},
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'authorizationCode',
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden',
default: 'https://auth.atlassian.com/authorize',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: 'https://auth.atlassian.com/oauth/token',
required: true,
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default: 'audience=api.atlassian.com&prompt=consent',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden',
default: 'header',
},
{
displayName: 'Custom Scopes',
name: 'customScopes',

View File

@ -0,0 +1,39 @@
/**
* Default OAuth2 scopes for the Atlassian product credentials — only scopes the
* nodes actually use, since Atlassian rejects any requested scope that isn't
* enabled on the OAuth app.
*
* https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/
* https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/
*/
export const offlineAccessScope = 'offline_access';
// Omits the previously shipped `manage:jira-user`: retired from Atlassian's catalog
// and silently dropped at consent, so no token ever carried it.
export const jiraDefaultScopes = [
'read:jira-user',
'read:jira-work',
'write:jira-work',
'manage:jira-webhook',
offlineAccessScope,
];
// Granular scopes only: the Confluence v2 REST API rejects classic scopes, and the
// v1 endpoints the node still uses accept granular ones too.
export const confluenceDefaultScopes = [
'read:page:confluence',
'write:page:confluence',
'read:hierarchical-content:confluence',
'read:space:confluence',
'read:attachment:confluence',
'read:comment:confluence',
'read:label:confluence',
'read:content-details:confluence',
'write:attachment:confluence',
'delete:attachment:confluence',
'write:comment:confluence',
'delete:comment:confluence',
'write:label:confluence',
'delete:page:confluence',
offlineAccessScope,
];

View File

@ -0,0 +1 @@
<svg fill="#0052CC" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Atlassian</title><path d="M7.12 11.084a.683.683 0 00-1.16.126L.075 22.974a.703.703 0 00.63 1.018h8.19a.678.678 0 00.63-.39c1.767-3.65.696-9.203-2.406-12.52zM11.434.386a15.515 15.515 0 00-.906 15.317l3.95 7.9a.703.703 0 00.628.388h8.19a.703.703 0 00.63-1.017L12.63.38a.664.664 0 00-1.196.006z"/></svg>

After

Width:  |  Height:  |  Size: 393 B

View File

@ -0,0 +1,54 @@
import type { INodeProperties } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import { AtlassianOAuth2Api } from '../AtlassianOAuth2Api.credentials';
import { OAuth2Api } from '../OAuth2Api.credentials';
describe('AtlassianOAuth2Api Credential', () => {
const atlassianOAuth2Api = new AtlassianOAuth2Api();
it('should have correct credential metadata', () => {
expect(atlassianOAuth2Api.name).toBe('atlassianOAuth2Api');
expect(atlassianOAuth2Api.extends).toEqual(['oAuth2Api']);
const domainProperty = atlassianOAuth2Api.properties.find((p) => p.name === 'domain');
expect(domainProperty?.displayName).toBe('Site URL');
expect(domainProperty?.required).toBe(true);
expect(domainProperty?.placeholder).toBe('https://your-site.atlassian.net');
const authUrlProperty = atlassianOAuth2Api.properties.find((p) => p.name === 'authUrl');
expect(authUrlProperty?.default).toBe('https://auth.atlassian.com/authorize');
const accessTokenUrlProperty = atlassianOAuth2Api.properties.find(
(p) => p.name === 'accessTokenUrl',
);
expect(accessTokenUrlProperty?.default).toBe('https://auth.atlassian.com/oauth/token');
const authQueryParamsProperty = atlassianOAuth2Api.properties.find(
(p) => p.name === 'authQueryParameters',
);
expect(authQueryParamsProperty?.default).toBe('audience=api.atlassian.com&prompt=consent');
const authenticationProperty = atlassianOAuth2Api.properties.find(
(p) => p.name === 'authentication',
);
expect(authenticationProperty?.default).toBe('header');
const grantTypeProperty = atlassianOAuth2Api.properties.find((p) => p.name === 'grantType');
expect(grantTypeProperty?.default).toBe('authorizationCode');
});
it('should leave the inherited scope field visible and editable', () => {
expect(atlassianOAuth2Api.properties.find((p) => p.name === 'scope')).toBeUndefined();
expect(atlassianOAuth2Api.properties.find((p) => p.name === 'customScopes')).toBeUndefined();
expect(atlassianOAuth2Api.properties.find((p) => p.name === 'enabledScopes')).toBeUndefined();
const resolved: INodeProperties[] = [];
NodeHelpers.mergeNodeProperties(resolved, new OAuth2Api().properties);
NodeHelpers.mergeNodeProperties(resolved, atlassianOAuth2Api.properties);
const scope = resolved.find((p) => p.name === 'scope');
expect(scope?.type).toBe('string');
expect(scope?.default).toBe('');
});
});

View File

@ -0,0 +1,198 @@
import { ClientOAuth2 } from '@n8n/client-oauth2';
import type { INodeProperties } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import nock from 'nock';
import { AtlassianOAuth2Api } from '../AtlassianOAuth2Api.credentials';
import { ConfluenceCloudOAuth2Api } from '../ConfluenceCloudOAuth2Api.credentials';
import { OAuth2Api } from '../OAuth2Api.credentials';
describe('ConfluenceCloudOAuth2Api Credential', () => {
const confluenceOAuth2Api = new ConfluenceCloudOAuth2Api();
const defaultScopes = (
confluenceOAuth2Api.properties.find((p) => p.name === 'enabledScopes')?.default as string
).split(' ');
// Endpoints derive from the resolved extends chain so base-credential regressions fail here
const resolvedProperties: INodeProperties[] = [];
NodeHelpers.mergeNodeProperties(resolvedProperties, new OAuth2Api().properties);
NodeHelpers.mergeNodeProperties(resolvedProperties, new AtlassianOAuth2Api().properties);
NodeHelpers.mergeNodeProperties(resolvedProperties, confluenceOAuth2Api.properties);
const resolvedDefault = (name: string) =>
resolvedProperties.find((p) => p.name === name)?.default as string;
const authorizationUri = resolvedDefault('authUrl');
const accessTokenUri = resolvedDefault('accessTokenUrl');
const baseUrl = new URL(accessTokenUri).origin;
const redirectUri = 'http://localhost:5678/rest/oauth2-credential/callback';
const clientId = 'test-client-id';
const clientSecret = 'test-client-secret';
const createOAuthClient = (scopes: string[]) =>
new ClientOAuth2({
clientId,
clientSecret,
accessTokenUri,
authorizationUri,
redirectUri,
scopes,
});
const mockTokenEndpoint = (code: string, responseScopes: string[]) => {
nock(baseUrl)
.post('/oauth/token', (body: Record<string, unknown>) => {
return (
body.code === code &&
body.grant_type === 'authorization_code' &&
body.redirect_uri === redirectUri
);
})
.reply(200, {
access_token: 'test-access-token',
token_type: 'Bearer',
expires_in: 3600,
scope: responseScopes.join(' '),
});
};
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
});
afterEach(() => {
nock.cleanAll();
});
it('should have correct credential metadata', () => {
expect(confluenceOAuth2Api.name).toBe('confluenceCloudOAuth2Api');
expect(confluenceOAuth2Api.extends).toEqual(['atlassianOAuth2Api']);
const pinnedScopes = [
'read:page:confluence',
'write:page:confluence',
'read:hierarchical-content:confluence',
'read:space:confluence',
'read:attachment:confluence',
'read:comment:confluence',
'read:label:confluence',
'read:content-details:confluence',
'write:attachment:confluence',
'delete:attachment:confluence',
'write:comment:confluence',
'delete:comment:confluence',
'write:label:confluence',
'delete:page:confluence',
'offline_access',
];
expect(defaultScopes).toEqual(pinnedScopes);
const scopeProperty = confluenceOAuth2Api.properties.find((p) => p.name === 'scope');
expect(scopeProperty?.type).toBe('hidden');
expect(scopeProperty?.default).toContain(pinnedScopes.join(' '));
});
it('should resolve the extends chain', () => {
const byName = (name: string) => resolvedProperties.find((p) => p.name === name);
const domain = byName('domain');
expect(domain?.type).toBe('string');
expect(domain?.required).toBe(true);
expect(domain?.displayName).toBe('Site URL');
expect(byName('grantType')?.default).toBe('authorizationCode');
expect(byName('authUrl')?.default).toBe('https://auth.atlassian.com/authorize');
expect(byName('accessTokenUrl')?.default).toBe('https://auth.atlassian.com/oauth/token');
expect(byName('authQueryParameters')?.default).toBe(
'audience=api.atlassian.com&prompt=consent',
);
expect(byName('authentication')?.default).toBe('header');
expect(byName('customScopes')?.default).toBe(false);
expect(byName('enabledScopes')?.default).toBe(defaultScopes.join(' '));
const scope = byName('scope');
expect(scope?.type).toBe('hidden');
expect(scope?.default).toBe(
'={{$self["customScopes"] ? $self["enabledScopes"] : "' + defaultScopes.join(' ') + '"}}',
);
});
it('should inherit the Site URL field from atlassianOAuth2Api', () => {
// Defined on the base; the credential must not shadow it
expect(confluenceOAuth2Api.properties.find((p) => p.name === 'domain')).toBeUndefined();
expect(confluenceOAuth2Api.properties.find((p) => p.name === 'siteUrl')).toBeUndefined();
});
describe('OAuth2 flow with default scopes', () => {
it('should include every default scope in the authorization URI', () => {
const oauthClient = createOAuthClient(defaultScopes);
const authUri = oauthClient.code.getUri();
for (const scope of defaultScopes) {
expect(authUri).toContain(encodeURIComponent(scope));
}
expect(authUri).toContain(`client_id=${clientId}`);
expect(authUri).toContain('response_type=code');
});
it('should retrieve a token carrying the default scopes', async () => {
const code = 'test-auth-code';
mockTokenEndpoint(code, defaultScopes);
const oauthClient = createOAuthClient(defaultScopes);
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
expect(token.data.scope).toBe(defaultScopes.join(' '));
});
});
describe('OAuth2 flow with custom scopes', () => {
const customScopes = [
'read:page:confluence',
'write:page:confluence',
'write:space:confluence',
'write:attachment:confluence',
'offline_access',
];
it('should include custom scopes in authorization URI', () => {
const oauthClient = createOAuthClient(customScopes);
const authUri = oauthClient.code.getUri();
expect(authUri).toContain('write%3Aspace%3Aconfluence');
expect(authUri).toContain('write%3Aattachment%3Aconfluence');
});
it('should retrieve token successfully with custom scopes', async () => {
const code = 'test-auth-code';
mockTokenEndpoint(code, customScopes);
const oauthClient = createOAuthClient(customScopes);
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
expect(token.data.scope).toContain('write:space:confluence');
expect(token.data.scope).toContain('write:attachment:confluence');
});
it('should handle minimal custom scopes', async () => {
const minimalScopes = ['read:page:confluence', 'offline_access'];
const code = 'test-auth-code';
mockTokenEndpoint(code, minimalScopes);
const oauthClient = createOAuthClient(minimalScopes);
const authUri = oauthClient.code.getUri();
expect(authUri).toContain('read%3Apage%3Aconfluence');
expect(authUri).toContain('offline_access');
expect(authUri).not.toContain('write%3Apage%3Aconfluence');
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
expect(token.data.scope).toContain('read:page:confluence');
expect(token.data.scope).not.toContain('write:page:confluence');
});
});
});

View File

@ -1,23 +1,29 @@
import { ClientOAuth2 } from '@n8n/client-oauth2';
import type { INodeProperties } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import nock from 'nock';
import { AtlassianOAuth2Api } from '../AtlassianOAuth2Api.credentials';
import { JiraSoftwareCloudOAuth2Api } from '../JiraSoftwareCloudOAuth2Api.credentials';
import { OAuth2Api } from '../OAuth2Api.credentials';
describe('JiraSoftwareCloudOAuth2Api Credential', () => {
const jiraOAuth2Api = new JiraSoftwareCloudOAuth2Api();
const defaultScopes = [
'read:jira-user',
'read:jira-work',
'write:jira-work',
'manage:jira-webhook',
'manage:jira-user',
'offline_access',
];
const defaultScopes = (
jiraOAuth2Api.properties.find((p) => p.name === 'enabledScopes')?.default as string
).split(' ');
// Shared OAuth2 configuration
const baseUrl = 'https://auth.atlassian.com';
const authorizationUri = `${baseUrl}/authorize`;
const accessTokenUri = `${baseUrl}/oauth/token`;
// Endpoints derive from the resolved extends chain so base-credential regressions fail here
const resolvedProperties: INodeProperties[] = [];
NodeHelpers.mergeNodeProperties(resolvedProperties, new OAuth2Api().properties);
NodeHelpers.mergeNodeProperties(resolvedProperties, new AtlassianOAuth2Api().properties);
NodeHelpers.mergeNodeProperties(resolvedProperties, jiraOAuth2Api.properties);
const resolvedDefault = (name: string) =>
resolvedProperties.find((p) => p.name === name)?.default as string;
const authorizationUri = resolvedDefault('authUrl');
const accessTokenUri = resolvedDefault('accessTokenUrl');
const baseUrl = new URL(accessTokenUri).origin;
const redirectUri = 'http://localhost:5678/rest/oauth2-credential/callback';
const clientId = 'test-client-id';
const clientSecret = 'test-client-secret';
@ -63,58 +69,67 @@ describe('JiraSoftwareCloudOAuth2Api Credential', () => {
it('should have correct credential metadata', () => {
expect(jiraOAuth2Api.name).toBe('jiraSoftwareCloudOAuth2Api');
expect(jiraOAuth2Api.extends).toEqual(['oAuth2Api']);
expect(jiraOAuth2Api.extends).toEqual(['atlassianOAuth2Api']);
const enabledScopesProperty = jiraOAuth2Api.properties.find((p) => p.name === 'enabledScopes');
expect(enabledScopesProperty?.default).toBe(
'read:jira-user read:jira-work write:jira-work manage:jira-webhook manage:jira-user offline_access',
expect(defaultScopes).toEqual([
'read:jira-user',
'read:jira-work',
'write:jira-work',
'manage:jira-webhook',
'offline_access',
]);
});
it('should resolve the extends chain', () => {
const resolved: INodeProperties[] = [];
NodeHelpers.mergeNodeProperties(resolved, new OAuth2Api().properties);
NodeHelpers.mergeNodeProperties(resolved, new AtlassianOAuth2Api().properties);
NodeHelpers.mergeNodeProperties(resolved, jiraOAuth2Api.properties);
const byName = (name: string) => resolved.find((p) => p.name === name);
const domain = byName('domain');
expect(domain?.type).toBe('string');
expect(domain?.required).toBe(true);
expect(domain?.displayName).toBe('Site URL');
expect(byName('grantType')?.default).toBe('authorizationCode');
expect(byName('authUrl')?.default).toBe('https://auth.atlassian.com/authorize');
expect(byName('accessTokenUrl')?.default).toBe('https://auth.atlassian.com/oauth/token');
expect(byName('authQueryParameters')?.default).toBe(
'audience=api.atlassian.com&prompt=consent',
);
expect(byName('authentication')?.default).toBe('header');
expect(byName('customScopes')?.default).toBe(false);
expect(byName('enabledScopes')?.default).toBe(defaultScopes.join(' '));
// Verify Atlassian-specific OAuth2 endpoints
const authUrlProperty = jiraOAuth2Api.properties.find((p) => p.name === 'authUrl');
expect(authUrlProperty?.default).toBe('https://auth.atlassian.com/authorize');
const accessTokenUrlProperty = jiraOAuth2Api.properties.find(
(p) => p.name === 'accessTokenUrl',
const scope = byName('scope');
expect(scope?.type).toBe('hidden');
expect(scope?.default).toBe(
'={{$self["customScopes"] ? $self["enabledScopes"] : "' + defaultScopes.join(' ') + '"}}',
);
expect(accessTokenUrlProperty?.default).toBe('https://auth.atlassian.com/oauth/token');
// Verify audience parameter is set (required for Atlassian OAuth2)
const authQueryParamsProperty = jiraOAuth2Api.properties.find(
(p) => p.name === 'authQueryParameters',
);
expect(authQueryParamsProperty?.default).toBe('audience=api.atlassian.com&prompt=consent');
});
describe('OAuth2 flow with default scopes', () => {
it('should include default scopes in authorization URI', () => {
it('should include every default scope in the authorization URI', () => {
const oauthClient = createOAuthClient(defaultScopes);
const authUri = oauthClient.code.getUri();
expect(authUri).toContain('scope=');
expect(authUri).toContain('read%3Ajira-user');
expect(authUri).toContain('read%3Ajira-work');
expect(authUri).toContain('write%3Ajira-work');
expect(authUri).toContain('manage%3Ajira-webhook');
expect(authUri).toContain('manage%3Ajira-user');
expect(authUri).toContain('offline_access');
for (const scope of defaultScopes) {
expect(authUri).toContain(encodeURIComponent(scope));
}
expect(authUri).toContain(`client_id=${clientId}`);
expect(authUri).toContain('response_type=code');
});
it('should retrieve token successfully with default scopes', async () => {
it('should retrieve a token carrying the default scopes', async () => {
const code = 'test-auth-code';
mockTokenEndpoint(code, defaultScopes);
const oauthClient = createOAuthClient(defaultScopes);
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
expect(token.data.scope).toContain('read:jira-user');
expect(token.data.scope).toContain('read:jira-work');
expect(token.data.scope).toContain('write:jira-work');
expect(token.data.scope).toContain('manage:jira-webhook');
expect(token.data.scope).toContain('manage:jira-user');
expect(token.data.scope).toContain('offline_access');
expect(token.data.scope).toBe(defaultScopes.join(' '));
});
});

View File

@ -3,28 +3,23 @@ import { NodeApiError, NodeOperationError } from 'n8n-workflow';
import type { Mock, Mocked } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import type { AccessibleResource } from '../../transport';
import {
clearCloudIdCache,
confluenceApiRequest,
getConfluenceCloudId,
matchSiteByHostname,
normalizeSiteUrl,
} from '../../transport';
import { clearAtlassianCloudIdCache } from '@utils/atlassian';
const accessibleResources: AccessibleResource[] = [
import { confluenceApiRequest } from '../../transport';
const accessibleResources = [
{ id: 'cloud-1', url: 'https://example.atlassian.net', name: 'example' },
{ id: 'cloud-2', url: 'https://Other.Atlassian.NET' },
];
describe('Confluence Transport', () => {
describe('confluenceApiRequest', () => {
let ctx: Mocked<IExecuteFunctions>;
let mockNode: INode;
let mockHttpRequestWithAuthentication: Mock;
beforeEach(() => {
vi.clearAllMocks();
clearCloudIdCache();
clearAtlassianCloudIdCache();
ctx = mockDeep<IExecuteFunctions>();
mockHttpRequestWithAuthentication = vi.fn().mockResolvedValue(accessibleResources);
ctx.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication;
@ -41,238 +36,93 @@ describe('Confluence Transport', () => {
ctx.getCredentials.mockResolvedValue({ domain: 'https://example.atlassian.net/wiki' });
});
describe('normalizeSiteUrl', () => {
it.each([
'example.atlassian.net',
'http://example.atlassian.net',
'https://example.atlassian.net/wiki',
'https://example.atlassian.net/wiki/spaces/ENG/pages/123',
'HTTPS://Example.Atlassian.NET/wiki/',
' example.atlassian.net ',
])('reduces "%s" to the lowercased hostname', (input) => {
expect(normalizeSiteUrl(input)).toBe('example.atlassian.net');
});
it('routes requests to https://api.atlassian.com/ex/confluence/{cloudId}', async () => {
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
it('throws on an unparseable site URL', () => {
expect(() => normalizeSiteUrl('not a url')).toThrow();
});
expect(ctx.getCredentials).toHaveBeenCalledWith('confluenceCloudOAuth2Api');
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2);
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
1,
'confluenceCloudOAuth2Api',
expect.objectContaining({
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
}),
);
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
2,
'confluenceCloudOAuth2Api',
expect.objectContaining({
url: 'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/pages',
}),
);
});
describe('matchSiteByHostname', () => {
it('matches case-insensitively on both sides', () => {
expect(matchSiteByHostname(accessibleResources, 'OTHER.atlassian.net')).toBe(
accessibleResources[1],
);
});
it('reuses the cached cloudId across requests', async () => {
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/spaces');
it('returns undefined when nothing matches', () => {
expect(matchSiteByHostname(accessibleResources, 'missing.atlassian.net')).toBeUndefined();
});
it('skips entries with malformed URLs instead of throwing', () => {
const withMalformed: AccessibleResource[] = [
{ id: 'bad', url: 'not a url' },
...accessibleResources,
];
expect(matchSiteByHostname(withMalformed, 'example.atlassian.net')).toBe(
accessibleResources[0],
);
});
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(3);
const urls = mockHttpRequestWithAuthentication.mock.calls.map(([, options]) => options.url);
expect(urls).toEqual([
'https://api.atlassian.com/oauth/token/accessible-resources',
'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/pages',
'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/spaces',
]);
});
describe('getConfluenceCloudId', () => {
it('resolves the cloudId by case-insensitive hostname match', async () => {
const cloudId = await getConfluenceCloudId.call(ctx, 'OTHER.atlassian.net/wiki');
it('passes method, body and qs through', async () => {
const body = { title: 'New page' };
const qs = { limit: 25 };
expect(cloudId).toBe('cloud-2');
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith(
'confluenceCloudOAuth2Api',
expect.objectContaining({
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
}),
);
});
await confluenceApiRequest.call(ctx, 'POST', '/wiki/api/v2/pages', body, qs);
it('serves the second lookup from the cache without refetching', async () => {
const first = await getConfluenceCloudId.call(ctx, 'https://example.atlassian.net/wiki');
const second = await getConfluenceCloudId.call(ctx, 'example.atlassian.net');
expect(first).toBe('cloud-1');
expect(second).toBe('cloud-1');
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
});
it('does not share the cache across credentials', async () => {
ctx.getNode.mockReturnValue({
...mockNode,
credentials: { confluenceCloudOAuth2Api: { id: 'cred-a', name: 'A' } },
});
await getConfluenceCloudId.call(ctx, 'example.atlassian.net');
ctx.getNode.mockReturnValue({
...mockNode,
credentials: { confluenceCloudOAuth2Api: { id: 'cred-b', name: 'B' } },
});
await getConfluenceCloudId.call(ctx, 'example.atlassian.net');
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2);
});
it('names the input and lists reachable site URLs when nothing matches', async () => {
const promise = getConfluenceCloudId.call(ctx, 'foo.atlassian.net');
await expect(promise).rejects.toThrow(NodeOperationError);
await expect(promise).rejects.toThrow(
'No Confluence site matched "foo.atlassian.net". This connection can access: https://example.atlassian.net, https://Other.Atlassian.NET',
);
});
it('says "no sites" when the connection reaches no Confluence sites', async () => {
mockHttpRequestWithAuthentication.mockResolvedValueOnce([]);
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
'can access: no sites',
);
});
it('treats a non-array accessible-resources body as no sites', async () => {
mockHttpRequestWithAuthentication.mockResolvedValueOnce({ error: 'unexpected' });
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
'can access: no sites',
);
});
it('caps the reachable-sites list at 5 entries', async () => {
mockHttpRequestWithAuthentication.mockResolvedValueOnce(
Array.from({ length: 7 }, (_, i) => ({
id: `cloud-${i}`,
url: `https://site-${i}.atlassian.net`,
})),
);
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
'This connection can access: https://site-0.atlassian.net, https://site-1.atlassian.net, https://site-2.atlassian.net, https://site-3.atlassian.net, https://site-4.atlassian.net, and 2 more',
);
});
it('omits malformed entries from the reachable-sites list', async () => {
mockHttpRequestWithAuthentication.mockResolvedValueOnce([
null,
{ id: 'no-url' },
{ id: 'cloud-1', url: 'https://example.atlassian.net' },
]);
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
'This connection can access: https://example.atlassian.net',
);
});
it('throws a NodeOperationError for an unparseable site URL', async () => {
await expect(getConfluenceCloudId.call(ctx, 'not a url')).rejects.toThrow(NodeOperationError);
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
});
it('wraps an accessible-resources failure in NodeApiError, keeping status and message', async () => {
mockHttpRequestWithAuthentication.mockRejectedValueOnce({
message: 'boom',
response: { status: 500 },
});
const error = await getConfluenceCloudId
.call(ctx, 'example.atlassian.net')
.then(() => null)
.catch((thrown: NodeApiError) => thrown);
expect(error).toBeInstanceOf(NodeApiError);
expect(error?.httpCode).toBe('500');
expect(error?.messages).toContain('boom');
});
it('passes an already-wrapped NodeApiError through unchanged', async () => {
// The realistic path: httpRequestWithAuthentication rejects with a NodeApiError already
const wrapped = new NodeApiError(mockNode, { message: 'boom' }, { httpCode: '429' });
mockHttpRequestWithAuthentication.mockRejectedValueOnce(wrapped);
await expect(getConfluenceCloudId.call(ctx, 'example.atlassian.net')).rejects.toBe(wrapped);
});
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
2,
'confluenceCloudOAuth2Api',
expect.objectContaining({ method: 'POST', body, qs, json: true }),
);
});
describe('confluenceApiRequest', () => {
it('routes requests to https://api.atlassian.com/ex/confluence/{cloudId}', async () => {
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
it('defaults body and qs to empty objects', async () => {
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
expect(ctx.getCredentials).toHaveBeenCalledWith('confluenceCloudOAuth2Api');
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2);
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
2,
'confluenceCloudOAuth2Api',
expect.objectContaining({
url: 'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/pages',
}),
);
});
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
2,
'confluenceCloudOAuth2Api',
expect.objectContaining({ body: {}, qs: {}, json: true }),
);
});
it('reuses the cached cloudId across requests', async () => {
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/spaces');
it('wraps request failures in NodeApiError, keeping status and message', async () => {
mockHttpRequestWithAuthentication
.mockResolvedValueOnce(accessibleResources)
.mockRejectedValueOnce({ message: 'boom', response: { status: 403 } });
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(3);
const urls = mockHttpRequestWithAuthentication.mock.calls.map(([, options]) => options.url);
expect(urls).toEqual([
'https://api.atlassian.com/oauth/token/accessible-resources',
'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/pages',
'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/spaces',
]);
});
const error = await confluenceApiRequest
.call(ctx, 'GET', '/wiki/api/v2/pages')
.then(() => null)
.catch((thrown: NodeApiError) => thrown);
it('passes method, body and qs through', async () => {
const body = { title: 'New page' };
const qs = { limit: 25 };
expect(error).toBeInstanceOf(NodeApiError);
expect(error?.httpCode).toBe('403');
expect(error?.messages).toContain('boom');
});
await confluenceApiRequest.call(ctx, 'POST', '/wiki/api/v2/pages', body, qs);
it('surfaces the cloudId lookup error when no site matches', async () => {
ctx.getCredentials.mockResolvedValue({ domain: 'https://missing.atlassian.net' });
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
2,
'confluenceCloudOAuth2Api',
expect.objectContaining({ method: 'POST', body, qs, json: true }),
);
});
await expect(confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages')).rejects.toThrow(
'No Confluence site matched "https://missing.atlassian.net"',
);
});
it('defaults body and qs to empty objects', async () => {
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
it('throws a NodeOperationError naming the Site URL field when the credential lacks it', async () => {
ctx.getCredentials.mockResolvedValue({});
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
2,
'confluenceCloudOAuth2Api',
expect.objectContaining({ body: {}, qs: {}, json: true }),
);
});
const promise = confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
it('wraps request failures in NodeApiError, keeping status and message', async () => {
mockHttpRequestWithAuthentication
.mockResolvedValueOnce(accessibleResources)
.mockRejectedValueOnce({ message: 'boom', response: { status: 403 } });
const error = await confluenceApiRequest
.call(ctx, 'GET', '/wiki/api/v2/pages')
.then(() => null)
.catch((thrown: NodeApiError) => thrown);
expect(error).toBeInstanceOf(NodeApiError);
expect(error?.httpCode).toBe('403');
expect(error?.messages).toContain('boom');
});
it('throws a NodeOperationError naming the Site URL field when the credential lacks it', async () => {
ctx.getCredentials.mockResolvedValue({});
const promise = confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
await expect(promise).rejects.toThrow(NodeOperationError);
await expect(promise).rejects.toThrow('Site URL');
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
});
await expect(promise).rejects.toThrow(NodeOperationError);
await expect(promise).rejects.toThrow('Site URL');
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
});
});

View File

@ -8,118 +8,10 @@ import type {
} from 'n8n-workflow';
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
// Owned by ENT-128 (parallel PR): coupled by name only, never import the class
import { getAtlassianApiBaseUrl, getAtlassianCloudId } from '@utils/atlassian';
export const CONFLUENCE_CREDENTIAL_NAME = 'confluenceCloudOAuth2Api';
// Bearer-token origin invariant: every request URL is string-concatenated onto this
// constant, so no caller input can change the host. Any future verbatim-URL param
// (paginator next-link, uri override) needs an origin check first, see the one in
// `microsoftApiRequest` (SharePoint v2 transport). User-supplied path segments in
// future endpoints must be encodeURIComponent-ed.
const ATLASSIAN_API_BASE = 'https://api.atlassian.com';
export interface AccessibleResource {
id: string;
url: string;
name?: string;
}
/**
* Pure hostname matcher, exported so ENT-128's credential Test can reuse the exact
* matching this node routes with. Case-insensitive, never an exact-string comparison
* (the trap in Jira's `getCloudId`).
*/
export function matchSiteByHostname(
resources: AccessibleResource[],
hostname: string,
): AccessibleResource | undefined {
const target = hostname.toLowerCase();
// Non-throwing on malformed entries: this is a reuse contract fed with an unvalidated API response
return resources.find((resource) => {
try {
return new URL(resource.url).hostname.toLowerCase() === target;
} catch {
return false;
}
});
}
// Module-level cache: credentialId:hostname → cloudId (`_cloudIdCache` in Jira's
// GenericFunctions is the precedent, but keyed per credential here: a shared hostname-only
// key makes cold vs warm failures distinguishable, letting one user infer that another
// credential on the instance reaches a given site).
// Lives for the n8n process; a moved/deleted site needs a restart to re-resolve (same ceiling as Jira).
// One asymmetry: a warm-cache hit skips the accessible-resources check, so a credential that
// lost access to a site it once resolved fails at the API call with Atlassian's 403.
const cloudIdCache = new Map<string, string>();
/** Test-only escape hatch for the module-level cache. */
export function clearCloudIdCache(): void {
cloudIdCache.clear();
}
/** Reduce any pasted site-URL form (scheme optional, `/wiki` or deeper paths, any casing) to its lowercased hostname. */
export function normalizeSiteUrl(siteUrl: string): string {
const trimmed = siteUrl.trim();
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
return new URL(withScheme).hostname.toLowerCase();
}
export async function getConfluenceCloudId(
this: IExecuteFunctions | ILoadOptionsFunctions,
siteUrl: string,
): Promise<string> {
let hostname: string;
try {
hostname = normalizeSiteUrl(siteUrl);
} catch {
throw new NodeOperationError(this.getNode(), `"${siteUrl}" is not a valid Confluence site URL`);
}
const credentialId = this.getNode().credentials?.[CONFLUENCE_CREDENTIAL_NAME]?.id ?? '';
const cacheKey = `${credentialId}:${hostname}`;
const cached = cloudIdCache.get(cacheKey);
if (cached) return cached;
let resources: AccessibleResource[];
try {
resources = await this.helpers.httpRequestWithAuthentication.call(
this,
CONFLUENCE_CREDENTIAL_NAME,
{
method: 'GET',
url: `${ATLASSIAN_API_BASE}/oauth/token/accessible-resources`,
json: true,
},
);
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
// A non-array body falls through to the no-match error instead of a raw TypeError
if (!Array.isArray(resources)) resources = [];
const site = matchSiteByHostname(resources, hostname);
if (!site) {
// Same guard class as matchSiteByHostname: malformed entries must not break the error message.
// Capped at 5 so the error stays readable and a bogus siteUrl cannot dump the full site list
// into persisted execution data.
const urls = resources
.filter((resource) => typeof resource?.url === 'string' && resource.url !== '')
.map((resource) => resource.url);
const reachable =
urls.slice(0, 5).join(', ') + (urls.length > 5 ? `, and ${urls.length - 5} more` : '');
throw new NodeOperationError(
this.getNode(),
`No Confluence site matched "${siteUrl}". This connection can access: ${reachable || 'no sites'}`,
);
}
cloudIdCache.set(cacheKey, site.id);
return site.id;
}
export async function confluenceApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
@ -128,8 +20,7 @@ export async function confluenceApiRequest(
qs: IDataObject = {},
): Promise<IDataObject> {
const credentials = await this.getCredentials(CONFLUENCE_CREDENTIAL_NAME);
// The key is `domain` (kept from Jira credentials for backwards compatibility)
// while the user-facing label is "Site URL"; see AtlassianOAuth2Api (ENT-128)
// Keyed `domain` for backwards compatibility with Jira credentials; labeled "Site URL" in the UI
const siteUrl = credentials.domain;
if (typeof siteUrl !== 'string' || siteUrl === '') {
throw new NodeOperationError(
@ -137,11 +28,18 @@ export async function confluenceApiRequest(
'The Confluence credential is missing the Site URL field',
);
}
const cloudId = await getConfluenceCloudId.call(this, siteUrl);
const cloudId = await getAtlassianCloudId.call(
this,
CONFLUENCE_CREDENTIAL_NAME,
siteUrl,
'confluence',
);
// The URL is concatenated onto the api.atlassian.com base, so caller input can't
// change the host; a future verbatim-URL param needs an origin check first.
const options: IHttpRequestOptions = {
method,
url: `${ATLASSIAN_API_BASE}/ex/confluence/${encodeURIComponent(cloudId)}${endpoint}`,
url: `${getAtlassianApiBaseUrl('confluence', cloudId)}${endpoint}`,
body,
qs,
json: true,

View File

@ -12,36 +12,10 @@ import type {
} from 'n8n-workflow';
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
import { getAtlassianApiBaseUrl, getAtlassianCloudId } from '@utils/atlassian';
import type { JiraServerInfo, JiraWebhook } from './types';
// Module-level cache: normalised domain → cloudId (persists for the life of the n8n process)
const _cloudIdCache = new Map<string, string>();
async function getCloudId(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
credentialType: string,
domain: string,
): Promise<string> {
const normalizedDomain = domain.replace(/\/$/, '');
if (_cloudIdCache.has(normalizedDomain)) return _cloudIdCache.get(normalizedDomain)!;
const resources = (await this.helpers.requestWithAuthentication.call(this, credentialType, {
uri: 'https://api.atlassian.com/oauth/token/accessible-resources',
json: true,
})) as Array<{ id: string; url: string }>;
const site = resources.find((r) => r.url === normalizedDomain);
if (!site) {
throw new NodeOperationError(
this.getNode(),
`No accessible Jira site found for domain: ${domain}. Make sure the domain matches your Atlassian site URL exactly.`,
);
}
_cloudIdCache.set(normalizedDomain, site.id);
return site.id;
}
export async function jiraSoftwareCloudApiRequest(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
endpoint: string,
@ -63,10 +37,16 @@ export async function jiraSoftwareCloudApiRequest(
domain = (await this.getCredentials('jiraSoftwareServerPatApi')).domain as string;
credentialType = 'jiraSoftwareServerPatApi';
} else if (jiraVersion === 'cloudOAuth2') {
const rawDomain = (await this.getCredentials('jiraSoftwareCloudOAuth2Api')).domain as string;
const rawDomain = (await this.getCredentials('jiraSoftwareCloudOAuth2Api')).domain;
credentialType = 'jiraSoftwareCloudOAuth2Api';
const cloudId = await getCloudId.call(this, credentialType, rawDomain);
domain = `https://api.atlassian.com/ex/jira/${cloudId}`;
if (typeof rawDomain !== 'string' || rawDomain === '') {
throw new NodeOperationError(
this.getNode(),
'The Jira credential is missing the Site URL field',
);
}
const cloudId = await getAtlassianCloudId.call(this, credentialType, rawDomain, 'jira');
domain = getAtlassianApiBaseUrl('jira', cloudId);
} else {
domain = (await this.getCredentials('jiraSoftwareCloudApi')).domain as string;
credentialType = 'jiraSoftwareCloudApi';

View File

@ -33,6 +33,20 @@ export const userOperations: INodeProperties[] = [
],
default: 'create',
},
{
displayName:
'Creating and deleting users requires the <code>manage:jira-configuration</code> scope, which is not included in the default scopes. Enable it on your OAuth app in the Atlassian developer console, add it in the credential via Custom Scopes, then reconnect.',
name: 'userManagementScopeNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
resource: ['user'],
operation: ['create', 'delete'],
jiraVersion: ['cloudOAuth2'],
},
},
},
];
export const userFields: INodeProperties[] = [

View File

@ -98,27 +98,24 @@ describe('Jira -> GenericFunctions', () => {
mockExecuteFunctions.getCredentials.mockResolvedValue({
domain: 'https://example.atlassian.net',
});
// First call returns accessible-resources, second call returns the actual API response
mockExecuteFunctions.helpers.requestWithAuthentication
.mockResolvedValueOnce([{ id: cloudId, url: 'https://example.atlassian.net' }])
.mockResolvedValueOnce({});
// cloudId lookup uses httpRequestWithAuthentication; the API request uses the legacy helper
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValueOnce([
{ id: cloudId, url: 'https://example.atlassian.net' },
]);
mockExecuteFunctions.helpers.requestWithAuthentication.mockResolvedValueOnce({});
await jiraSoftwareCloudApiRequest.call(mockExecuteFunctions, '/api/2/myself', 'GET');
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith(
'jiraSoftwareCloudOAuth2Api',
);
// First call must be the accessible-resources lookup
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenNthCalledWith(
1,
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
'jiraSoftwareCloudOAuth2Api',
expect.objectContaining({
uri: 'https://api.atlassian.com/oauth/token/accessible-resources',
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
}),
);
// Second call must use the api.atlassian.com base URL with cloudId
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenNthCalledWith(
2,
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'jiraSoftwareCloudOAuth2Api',
expect.objectContaining({
uri: `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/2/myself`,
@ -126,6 +123,21 @@ describe('Jira -> GenericFunctions', () => {
);
});
it('should throw a NodeOperationError naming the Site URL field when the credential lacks it', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('cloudOAuth2');
mockExecuteFunctions.getCredentials.mockResolvedValue({});
const promise = jiraSoftwareCloudApiRequest.call(
mockExecuteFunctions,
'/api/2/myself',
'GET',
);
await expect(promise).rejects.toThrow('Site URL');
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).not.toHaveBeenCalled();
expect(mockExecuteFunctions.helpers.requestWithAuthentication).not.toHaveBeenCalled();
});
it('should use jiraSoftwareServerApi credential for jiraVersion "server"', async () => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('server');
mockExecuteFunctions.getCredentials.mockResolvedValue({

View File

@ -1,4 +1,3 @@
import { mock, mockDeep } from 'vitest-mock-extended';
import type {
ICredentialDataDecryptedObject,
IDataObject,
@ -6,23 +5,31 @@ import type {
INode,
IWebhookFunctions,
} from 'n8n-workflow';
import { mock, mockDeep } from 'vitest-mock-extended';
import { testWebhookTriggerNode } from '@test/nodes/TriggerHelpers';
import { clearAtlassianCloudIdCache } from '@utils/atlassian';
import { JiraTrigger } from '../JiraTrigger.node';
import {
allEvents,
OAUTH2_WEBHOOK_REFRESH_INTERVAL_MS,
OAUTH2_WEBHOOK_EXPIRY_BUFFER_MS,
OAUTH2_SUPPORTED_WEBHOOK_EVENTS,
} from '../GenericFunctions';
import { JiraTrigger } from '../JiraTrigger.node';
describe('JiraTrigger', () => {
describe('Webhook lifecycle', () => {
let staticData: IDataObject;
const OAUTH2_DOMAIN = 'https://test-oauth2.atlassian.net';
const OAUTH2_CLOUD_ID = 'test-cloud-id';
const mockCloudIdLookup = () =>
vi.fn().mockResolvedValueOnce([{ id: OAUTH2_CLOUD_ID, url: OAUTH2_DOMAIN }]);
beforeEach(() => {
staticData = {};
clearAtlassianCloudIdCache();
});
function mockHookFunctions(
@ -185,6 +192,7 @@ describe('JiraTrigger', () => {
function mockOAuth2HookFunctions(
mockRequest: IHookFunctions['helpers']['requestWithAuthentication'],
mockHttpRequest?: IHookFunctions['helpers']['httpRequestWithAuthentication'],
) {
return mockDeep<IHookFunctions>({
getWorkflowStaticData: () => staticData,
@ -198,29 +206,33 @@ describe('JiraTrigger', () => {
}),
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
({ domain }) as T,
helpers: { requestWithAuthentication: mockRequest },
helpers: {
requestWithAuthentication: mockRequest,
...(mockHttpRequest ? { httpRequestWithAuthentication: mockHttpRequest } : {}),
},
});
}
const baseApiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/webhook`;
// checkExists — GET /api/3/webhook
// checkExists — the cloudId lookup, then GET /api/3/webhook
const mockCloudIdRequest = vi.fn().mockResolvedValueOnce(accessibleResources);
const mockExistsRequest = vi
.fn()
.mockResolvedValueOnce(accessibleResources) // getCloudId call
.mockResolvedValueOnce({ isLast: true, maxResults: 50, startAt: 0, total: 0, values: [] });
const exists = await trigger.webhookMethods.default?.checkExists.call(
mockOAuth2HookFunctions(mockExistsRequest),
mockOAuth2HookFunctions(mockExistsRequest, mockCloudIdRequest),
);
expect(mockExistsRequest).toHaveBeenCalledTimes(2);
expect(mockExistsRequest).toHaveBeenCalledWith(
expect(mockCloudIdRequest).toHaveBeenCalledTimes(1);
expect(mockCloudIdRequest).toHaveBeenCalledWith(
'jiraSoftwareCloudOAuth2Api',
expect.objectContaining({
uri: 'https://api.atlassian.com/oauth/token/accessible-resources',
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
}),
);
expect(mockExistsRequest).toHaveBeenCalledTimes(1);
expect(mockExistsRequest).toHaveBeenCalledWith(
'jiraSoftwareCloudOAuth2Api',
expect.objectContaining({ uri: baseApiUrl, method: 'GET' }),
@ -274,7 +286,7 @@ describe('JiraTrigger', () => {
test('should refresh OAuth2 webhook in checkExists when near expiry', async () => {
const trigger = new JiraTrigger();
const cloudId = 'test-cloud-id'; // already cached from previous OAuth2 test
const cloudId = 'test-cloud-id';
const domain = 'https://test-oauth2.atlassian.net';
const baseApiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/webhook`;
@ -311,7 +323,10 @@ describe('JiraTrigger', () => {
}),
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
({ domain }) as T,
helpers: { requestWithAuthentication: mockRequest },
helpers: {
requestWithAuthentication: mockRequest,
httpRequestWithAuthentication: mockCloudIdLookup(),
},
});
const exists = await trigger.webhookMethods.default?.checkExists.call(hookFns);
@ -361,7 +376,10 @@ describe('JiraTrigger', () => {
}),
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
({ domain }) as T,
helpers: { requestWithAuthentication: mockRequest },
helpers: {
requestWithAuthentication: mockRequest,
httpRequestWithAuthentication: mockCloudIdLookup(),
},
});
const exists = await trigger.webhookMethods.default?.checkExists.call(hookFns);
@ -393,6 +411,9 @@ describe('JiraTrigger', () => {
webhookFns.getBodyData.mockReturnValue({});
webhookFns.getQueryData.mockReturnValue({});
webhookFns.getCredentials.mockResolvedValue({ domain } as ICredentialDataDecryptedObject);
webhookFns.helpers.httpRequestWithAuthentication.mockResolvedValueOnce([
{ id: cloudId, url: domain },
]);
// PUT /api/3/webhook/refresh
webhookFns.helpers.requestWithAuthentication.mockResolvedValueOnce({});
webhookFns.helpers.returnJsonArray.mockImplementation((data: IDataObject | IDataObject[]) => [
@ -444,7 +465,7 @@ describe('JiraTrigger', () => {
test('should filter unsupported events when registering OAuth2 webhook', async () => {
const trigger = new JiraTrigger();
const domain = 'https://test-oauth2.atlassian.net'; // cloudId already cached
const domain = 'https://test-oauth2.atlassian.net';
const cloudId = 'test-cloud-id';
const baseApiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/webhook`;
@ -476,7 +497,10 @@ describe('JiraTrigger', () => {
}),
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
({ domain }) as T,
helpers: { requestWithAuthentication: mockRequest },
helpers: {
requestWithAuthentication: mockRequest,
httpRequestWithAuthentication: mockCloudIdLookup(),
},
});
await trigger.webhookMethods.default?.create.call(hookFns);
@ -516,7 +540,10 @@ describe('JiraTrigger', () => {
}),
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
({ domain }) as T,
helpers: { requestWithAuthentication: mockRequest },
helpers: {
requestWithAuthentication: mockRequest,
httpRequestWithAuthentication: mockCloudIdLookup(),
},
});
await trigger.webhookMethods.default?.create.call(hookFns);
@ -559,7 +586,10 @@ describe('JiraTrigger', () => {
}),
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
({ domain }) as T,
helpers: { requestWithAuthentication: mockRequest },
helpers: {
requestWithAuthentication: mockRequest,
httpRequestWithAuthentication: mockCloudIdLookup(),
},
});
await trigger.webhookMethods.default?.create.call(hookFns);
@ -587,7 +617,10 @@ describe('JiraTrigger', () => {
}),
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
({ domain }) as T,
helpers: { requestWithAuthentication: vi.fn().mockResolvedValueOnce([]) },
helpers: {
requestWithAuthentication: vi.fn().mockResolvedValueOnce([]),
httpRequestWithAuthentication: mockCloudIdLookup(),
},
});
await expect(trigger.webhookMethods.default?.create.call(hookFns)).rejects.toThrow(
@ -618,7 +651,10 @@ describe('JiraTrigger', () => {
}),
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
({ domain }) as T,
helpers: { requestWithAuthentication: mockRequest },
helpers: {
requestWithAuthentication: mockRequest,
httpRequestWithAuthentication: mockCloudIdLookup(),
},
});
await trigger.webhookMethods.default?.create.call(hookFns);

View File

@ -50,6 +50,7 @@
"dist/credentials/ApiTemplateIoApi.credentials.js",
"dist/credentials/AsanaApi.credentials.js",
"dist/credentials/AsanaOAuth2Api.credentials.js",
"dist/credentials/AtlassianOAuth2Api.credentials.js",
"dist/credentials/Auth0ManagementApi.credentials.js",
"dist/credentials/AutopilotApi.credentials.js",
"dist/credentials/Aws.credentials.js",
@ -87,6 +88,7 @@
"dist/credentials/ClockifyApi.credentials.js",
"dist/credentials/CockpitApi.credentials.js",
"dist/credentials/CodaApi.credentials.js",
"dist/credentials/ConfluenceCloudOAuth2Api.credentials.js",
"dist/credentials/ContentfulApi.credentials.js",
"dist/credentials/ConvertApi.credentials.js",
"dist/credentials/ConvertKitApi.credentials.js",

View File

@ -0,0 +1,290 @@
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
import type { Mock, Mocked } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import type { AccessibleResource } from '../atlassian';
import {
clearAtlassianCloudIdCache,
extractAtlassianSiteHostname,
getAtlassianApiBaseUrl,
getAtlassianCloudId,
} from '../atlassian';
describe('extractAtlassianSiteHostname', () => {
it.each([
['https://example.atlassian.net', 'example.atlassian.net'],
['http://example.atlassian.net', 'example.atlassian.net'],
['example.atlassian.net', 'example.atlassian.net'],
['https://example.atlassian.net/', 'example.atlassian.net'],
['https://example.atlassian.net/wiki', 'example.atlassian.net'],
['https://example.atlassian.net/wiki/spaces/DOCS/pages/123', 'example.atlassian.net'],
['example.atlassian.net/wiki/', 'example.atlassian.net'],
['HTTPS://Example.Atlassian.NET/Wiki', 'example.atlassian.net'],
[' https://example.atlassian.net ', 'example.atlassian.net'],
])('should extract %s → %s', (input, expected) => {
expect(extractAtlassianSiteHostname(input)).toBe(expected);
});
it.each([[''], [' '], ['https://']])('should throw on unparseable input %j', (input) => {
expect(() => extractAtlassianSiteHostname(input)).toThrow();
});
});
describe('getAtlassianApiBaseUrl', () => {
it('should build the product-scoped API base URL', () => {
expect(getAtlassianApiBaseUrl('confluence', 'abc-123')).toBe(
'https://api.atlassian.com/ex/confluence/abc-123',
);
expect(getAtlassianApiBaseUrl('jira', 'abc-123')).toBe(
'https://api.atlassian.com/ex/jira/abc-123',
);
});
it('should URL-encode the cloudId', () => {
expect(getAtlassianApiBaseUrl('jira', 'a/b c')).toBe(
'https://api.atlassian.com/ex/jira/a%2Fb%20c',
);
});
});
describe('getAtlassianCloudId', () => {
const credentialType = 'confluenceCloudOAuth2Api';
const accessibleResources: AccessibleResource[] = [
{ id: 'cloud-1', url: 'https://example.atlassian.net', name: 'example' },
{ id: 'cloud-2', url: 'https://Other.Atlassian.NET' },
];
let ctx: Mocked<IExecuteFunctions>;
let mockNode: INode;
let mockHttpRequestWithAuthentication: Mock;
beforeEach(() => {
vi.clearAllMocks();
clearAtlassianCloudIdCache();
ctx = mockDeep<IExecuteFunctions>();
mockHttpRequestWithAuthentication = vi.fn().mockResolvedValue(accessibleResources);
ctx.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication;
mockNode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.confluence',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
ctx.getNode.mockReturnValue(mockNode);
});
it('should resolve the cloudId via accessible-resources', async () => {
const result = await getAtlassianCloudId.call(
ctx,
credentialType,
'https://example.atlassian.net',
'confluence',
);
expect(result).toBe('cloud-1');
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith(
credentialType,
expect.objectContaining({
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
}),
);
});
it.each([
['example.atlassian.net'],
['http://example.atlassian.net'],
['https://example.atlassian.net/'],
['https://example.atlassian.net/wiki/spaces/DOCS'],
['EXAMPLE.Atlassian.NET/wiki'],
])('should resolve pasted form %s', async (siteUrl) => {
const result = await getAtlassianCloudId.call(ctx, credentialType, siteUrl, 'confluence');
expect(result).toBe('cloud-1');
});
it('should match hostnames case-insensitively on both sides', async () => {
const result = await getAtlassianCloudId.call(
ctx,
credentialType,
'OTHER.atlassian.net/wiki',
'confluence',
);
expect(result).toBe('cloud-2');
});
it('should skip entries with malformed URLs instead of throwing', async () => {
mockHttpRequestWithAuthentication.mockResolvedValueOnce([
{ id: 'bad', url: 'not a url' },
...accessibleResources,
]);
const result = await getAtlassianCloudId.call(
ctx,
credentialType,
'example.atlassian.net',
'confluence',
);
expect(result).toBe('cloud-1');
});
it('should serve the second lookup from the cache without refetching', async () => {
const first = await getAtlassianCloudId.call(
ctx,
credentialType,
'https://example.atlassian.net/wiki',
'confluence',
);
const second = await getAtlassianCloudId.call(
ctx,
credentialType,
'example.atlassian.net',
'confluence',
);
expect(first).toBe('cloud-1');
expect(second).toBe('cloud-1');
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
});
it('should share one cache entry across products for the same credential and hostname', async () => {
const viaJira = await getAtlassianCloudId.call(
ctx,
credentialType,
'example.atlassian.net',
'jira',
);
const viaConfluence = await getAtlassianCloudId.call(
ctx,
credentialType,
'example.atlassian.net',
'confluence',
);
expect(viaJira).toBe('cloud-1');
expect(viaConfluence).toBe('cloud-1');
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
});
it('should not share the cache across credentials', async () => {
ctx.getNode.mockReturnValue({
...mockNode,
credentials: { [credentialType]: { id: 'cred-a', name: 'A' } },
});
await getAtlassianCloudId.call(ctx, credentialType, 'example.atlassian.net', 'confluence');
ctx.getNode.mockReturnValue({
...mockNode,
credentials: { [credentialType]: { id: 'cred-b', name: 'B' } },
});
await getAtlassianCloudId.call(ctx, credentialType, 'example.atlassian.net', 'confluence');
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2);
});
it('should name the input and list reachable site URLs when nothing matches', async () => {
const promise = getAtlassianCloudId.call(
ctx,
credentialType,
'foo.atlassian.net',
'confluence',
);
await expect(promise).rejects.toThrow(NodeOperationError);
await expect(promise).rejects.toThrow(
'No Confluence site matched "foo.atlassian.net". This connection can access: https://example.atlassian.net, https://Other.Atlassian.NET.',
);
});
it('should name the product in the error message', async () => {
await expect(
getAtlassianCloudId.call(
ctx,
'jiraSoftwareCloudOAuth2Api',
'https://unknown.atlassian.net',
'jira',
),
).rejects.toThrow(/No Jira site matched/);
});
it('should say "no sites" when the connection can access none', async () => {
mockHttpRequestWithAuthentication.mockResolvedValueOnce([]);
await expect(
getAtlassianCloudId.call(ctx, credentialType, 'foo.atlassian.net', 'confluence'),
).rejects.toThrow('can access: no sites');
});
it('should treat a non-array accessible-resources body as no sites', async () => {
mockHttpRequestWithAuthentication.mockResolvedValueOnce({ error: 'unexpected' });
await expect(
getAtlassianCloudId.call(ctx, credentialType, 'foo.atlassian.net', 'confluence'),
).rejects.toThrow('can access: no sites');
});
it('should cap the reachable-sites list at 5 entries', async () => {
mockHttpRequestWithAuthentication.mockResolvedValueOnce(
Array.from({ length: 7 }, (_, i) => ({
id: `cloud-${i}`,
url: `https://site-${i}.atlassian.net`,
})),
);
await expect(
getAtlassianCloudId.call(ctx, credentialType, 'foo.atlassian.net', 'confluence'),
).rejects.toThrow(
'This connection can access: https://site-0.atlassian.net, https://site-1.atlassian.net, https://site-2.atlassian.net, https://site-3.atlassian.net, https://site-4.atlassian.net, and 2 more',
);
});
it('should omit malformed entries from the reachable-sites list', async () => {
mockHttpRequestWithAuthentication.mockResolvedValueOnce([
null,
{ id: 'no-url' },
{ id: 'cloud-1', url: 'https://example.atlassian.net' },
]);
await expect(
getAtlassianCloudId.call(ctx, credentialType, 'foo.atlassian.net', 'confluence'),
).rejects.toThrow('This connection can access: https://example.atlassian.net');
});
it('should throw a clear NodeOperationError on unparseable site URL', async () => {
await expect(getAtlassianCloudId.call(ctx, credentialType, '', 'confluence')).rejects.toThrow(
'"" is not a valid Atlassian site URL',
);
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
});
it('should wrap an accessible-resources failure in NodeApiError, keeping status and message', async () => {
mockHttpRequestWithAuthentication.mockRejectedValueOnce({
message: 'boom',
response: { status: 500 },
});
const error = await getAtlassianCloudId
.call(ctx, credentialType, 'example.atlassian.net', 'confluence')
.then(() => null)
.catch((thrown: NodeApiError) => thrown);
expect(error).toBeInstanceOf(NodeApiError);
expect(error?.httpCode).toBe('500');
expect(error?.messages).toContain('boom');
});
it('should pass an already-wrapped NodeApiError through unchanged', async () => {
const wrapped = new NodeApiError(mockNode, { message: 'boom' }, { httpCode: '429' });
mockHttpRequestWithAuthentication.mockRejectedValueOnce(wrapped);
await expect(
getAtlassianCloudId.call(ctx, credentialType, 'example.atlassian.net', 'confluence'),
).rejects.toBe(wrapped);
});
});

View File

@ -0,0 +1,118 @@
import type {
IExecuteFunctions,
IHookFunctions,
ILoadOptionsFunctions,
IPollFunctions,
IWebhookFunctions,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
type AtlassianContext =
| IHookFunctions
| IExecuteFunctions
| ILoadOptionsFunctions
| IPollFunctions
| IWebhookFunctions;
export type AtlassianProduct = 'jira' | 'confluence';
const PRODUCT_NAMES: Record<AtlassianProduct, string> = {
jira: 'Jira',
confluence: 'Confluence',
};
export interface AccessibleResource {
id: string;
url: string;
name?: string;
}
// credentialId:hostname → cloudId, kept for the life of the process. Keyed per
// credential so cache timing can't reveal which sites other credentials reach.
const cloudIdCache = new Map<string, string>();
export function clearAtlassianCloudIdCache() {
cloudIdCache.clear();
}
/**
* Extracts the hostname from a site URL pasted in any reasonable form: with or
* without a scheme, with a trailing slash, with a path like /wiki, any casing.
* Throws on input that doesn't contain a parseable hostname.
*/
export function extractAtlassianSiteHostname(siteUrl: string): string {
const trimmed = siteUrl.trim();
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
return new URL(withScheme).hostname.toLowerCase();
}
export function getAtlassianApiBaseUrl(product: AtlassianProduct, cloudId: string): string {
return `https://api.atlassian.com/ex/${product}/${encodeURIComponent(cloudId)}`;
}
/**
* Resolves a site URL to its cloudId by matching its hostname (case-insensitively)
* against the sites the OAuth2 connection can access.
*/
export async function getAtlassianCloudId(
this: AtlassianContext,
credentialType: string,
siteUrl: string,
product: AtlassianProduct,
): Promise<string> {
let hostname: string;
try {
hostname = extractAtlassianSiteHostname(siteUrl);
} catch {
throw new NodeOperationError(
this.getNode(),
`"${siteUrl}" is not a valid Atlassian site URL. Expected something like https://your-site.atlassian.net.`,
);
}
const rawCredentialId = this.getNode().credentials?.[credentialType]?.id;
const credentialId = typeof rawCredentialId === 'string' ? rawCredentialId : '';
const cacheKey = `${credentialId}:${hostname}`;
const cached = cloudIdCache.get(cacheKey);
if (cached) return cached;
let resources: AccessibleResource[];
try {
resources = await this.helpers.httpRequestWithAuthentication.call(this, credentialType, {
method: 'GET',
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
json: true,
});
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);
}
if (!Array.isArray(resources)) resources = [];
const site = resources.find((resource) => {
try {
return new URL(resource.url).hostname.toLowerCase() === hostname;
} catch {
return false;
}
});
if (!site) {
// Capped at 5 so a bogus siteUrl can't dump the full site list into persisted execution data
const urls = resources
.filter((resource) => typeof resource?.url === 'string' && resource.url !== '')
.map((resource) => resource.url);
const reachable =
urls.slice(0, 5).join(', ') + (urls.length > 5 ? `, and ${urls.length - 5} more` : '');
throw new NodeOperationError(
this.getNode(),
`No ${PRODUCT_NAMES[product]} site matched "${siteUrl}". This connection can access: ${
reachable || 'no sites'
}.`,
);
}
cloudIdCache.set(cacheKey, site.id);
return site.id;
}