feat(Wordpress Node): Add support for OAuth2

This commit is contained in:
Jonathan Bennetts
2026-03-16 19:30:54 +00:00
parent fbccfbc7f5
commit b3473fd75d
6 changed files with 401 additions and 4 deletions

View File

@@ -0,0 +1,112 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class WordpressOAuth2Api implements ICredentialType {
name = 'wordpressOAuth2Api';
displayName = 'WordPress OAuth2 API';
extends = ['oAuth2Api'];
documentationUrl = 'wordpress';
properties: INodeProperties[] = [
{
displayName:
'OAuth2 authentication works with WordPress.com-hosted sites only. For self-hosted WordPress, use the WordPress API (Basic Auth) credential instead.',
name: 'wordpressComNotice',
type: 'notice',
default: '',
},
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'authorizationCode',
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden',
default: 'https://public-api.wordpress.com/oauth2/authorize',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: 'https://public-api.wordpress.com/oauth2/token',
required: true,
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden',
default: 'header',
},
{
displayName: 'Use Custom Domain',
name: 'customDomain',
type: 'boolean',
default: false,
description:
'Whether your WordPress.com site uses a custom domain instead of a .wordpress.com subdomain',
},
{
displayName: 'Custom Domain',
name: 'customDomainUrl',
type: 'string',
displayOptions: {
show: {
customDomain: [true],
},
},
default: '',
placeholder: 'myblog.com',
description:
"Your WordPress.com site's custom domain. Used as the site identifier in API requests — calls still route through public-api.wordpress.com.",
},
{
displayName: 'Custom Scopes',
name: 'customScopes',
type: 'boolean',
default: false,
description: 'Whether to define custom OAuth2 scopes instead of the defaults',
},
{
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: 'global',
description: 'Space-separated list of OAuth2 scopes to request',
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default: '={{$self["customScopes"] ? $self["enabledScopes"] : "global"}}',
},
];
}

View File

@@ -0,0 +1,159 @@
import { ClientOAuth2 } from '@n8n/client-oauth2';
import nock from 'nock';
import { WordpressOAuth2Api } from '../WordpressOAuth2Api.credentials';
describe('WordpressOAuth2Api Credential', () => {
const credential = new WordpressOAuth2Api();
// Shared OAuth2 configuration
const authorizationUri = 'https://public-api.wordpress.com/oauth2/authorize';
const accessTokenUri = 'https://public-api.wordpress.com/oauth2/token';
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('https://public-api.wordpress.com')
.post('/oauth2/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: 64800,
scope: responseScopes.join(' '),
blog_id: '12345',
blog_url: 'https://myblog.wordpress.com',
});
};
beforeAll(() => {
nock.disableNetConnect();
});
afterAll(() => {
nock.restore();
});
afterEach(() => {
nock.cleanAll();
});
it('should have correct credential metadata', () => {
expect(credential.name).toBe('wordpressOAuth2Api');
expect(credential.extends).toEqual(['oAuth2Api']);
const authUrlProperty = credential.properties.find((p) => p.name === 'authUrl');
expect(authUrlProperty?.default).toBe('https://public-api.wordpress.com/oauth2/authorize');
const accessTokenUrlProperty = credential.properties.find((p) => p.name === 'accessTokenUrl');
expect(accessTokenUrlProperty?.default).toBe('https://public-api.wordpress.com/oauth2/token');
});
it('should have a notice about WordPress.com-only support', () => {
const noticeProperty = credential.properties.find((p) => p.name === 'wordpressComNotice');
expect(noticeProperty).toBeDefined();
expect(noticeProperty?.type).toBe('notice');
expect(noticeProperty?.default).toBe('');
});
it('should have custom domain toggle defaulting to false', () => {
const customDomainProperty = credential.properties.find((p) => p.name === 'customDomain');
expect(customDomainProperty?.default).toBe(false);
});
it('should have custom scopes toggle defaulting to false', () => {
const customScopesProperty = credential.properties.find((p) => p.name === 'customScopes');
expect(customScopesProperty?.default).toBe(false);
});
it('should have default scope of global', () => {
const enabledScopesProperty = credential.properties.find((p) => p.name === 'enabledScopes');
expect(enabledScopesProperty?.default).toBe('global');
});
describe('OAuth2 flow with default scope', () => {
it('should include global scope in authorization URI', () => {
const oauthClient = createOAuthClient(['global']);
const authUri = oauthClient.code.getUri();
expect(authUri).toContain('scope=');
expect(authUri).toContain('global');
expect(authUri).toContain(`client_id=${clientId}`);
expect(authUri).toContain('response_type=code');
});
it('should retrieve token successfully with global scope', async () => {
const code = 'test-auth-code';
mockTokenEndpoint(code, ['global']);
const oauthClient = createOAuthClient(['global']);
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
expect(token.data.scope).toContain('global');
expect(token.data.blog_id).toBe('12345');
expect(token.data.blog_url).toBe('https://myblog.wordpress.com');
});
});
describe('OAuth2 flow with custom scopes', () => {
const customScopes = ['posts', 'media'];
it('should include custom scopes in authorization URI', () => {
const oauthClient = createOAuthClient(customScopes);
const authUri = oauthClient.code.getUri();
expect(authUri).toContain('scope=');
expect(authUri).toContain('posts');
expect(authUri).toContain('media');
expect(authUri).not.toContain('global');
});
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('posts');
expect(token.data.scope).toContain('media');
});
it('should handle completely different custom scopes', async () => {
const differentScopes = ['comments', 'stats'];
const code = 'test-auth-code';
mockTokenEndpoint(code, differentScopes);
const oauthClient = createOAuthClient(differentScopes);
const authUri = oauthClient.code.getUri();
expect(authUri).toContain('comments');
expect(authUri).toContain('stats');
expect(authUri).not.toContain('global');
expect(authUri).not.toContain('posts');
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
expect(token.data.scope).toContain('comments');
expect(token.data.scope).toContain('stats');
expect(token.data.scope).not.toContain('global');
expect(token.data.scope).not.toContain('posts');
});
});
});

View File

@@ -18,7 +18,27 @@ export async function wordpressApiRequest(
uri?: string,
option: IDataObject = {},
): Promise<any> {
const credentials = await this.getCredentials('wordpressApi');
const authType = this.getNodeParameter('authType', 0, 'basicAuth') as string;
const isOAuth2 = authType === 'oAuth2';
let baseUri: string;
let credentialType: string;
let rejectUnauthorized: boolean | undefined;
if (isOAuth2) {
const credentials = await this.getCredentials('wordpressOAuth2Api');
credentialType = 'wordpressOAuth2Api';
const sitePrefix =
credentials.customDomain && credentials.customDomainUrl
? `/sites/${credentials.customDomainUrl as string}`
: '';
baseUri = `https://public-api.wordpress.com/wp/v2${sitePrefix}`;
} else {
const credentials = await this.getCredentials('wordpressApi');
credentialType = 'wordpressApi';
baseUri = `${credentials.url as string}/wp-json/wp/v2`;
rejectUnauthorized = !(credentials.allowUnauthorizedCerts as boolean);
}
let options: IRequestOptions = {
headers: {
@@ -29,8 +49,8 @@ export async function wordpressApiRequest(
method,
qs,
body,
uri: uri || `${credentials.url}/wp-json/wp/v2${resource}`,
rejectUnauthorized: !credentials.allowUnauthorizedCerts,
uri: uri ?? `${baseUri}${resource}`,
...(rejectUnauthorized !== undefined ? { rejectUnauthorized } : {}),
json: true,
};
options = Object.assign({}, options, option);
@@ -38,7 +58,6 @@ export async function wordpressApiRequest(
delete options.body;
}
try {
const credentialType = 'wordpressApi';
return await this.helpers.requestWithAuthentication.call(this, credentialType, options);
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);

View File

@@ -36,9 +36,40 @@ export class Wordpress implements INodeType {
{
name: 'wordpressApi',
required: true,
displayOptions: {
show: {
authType: ['basicAuth'],
},
},
},
{
name: 'wordpressOAuth2Api',
required: true,
displayOptions: {
show: {
authType: ['oAuth2'],
},
},
},
],
properties: [
{
displayName: 'Authentication',
name: 'authType',
type: 'options',
options: [
{
name: 'Basic Auth',
value: 'basicAuth',
},
{
name: 'OAuth2 (WordPress.com)',
value: 'oAuth2',
},
],
default: 'basicAuth',
description: 'The authentication method to use',
},
{
displayName: 'Resource',
name: 'resource',

View File

@@ -12,10 +12,16 @@ describe('Wordpress > GenericFunctions', () => {
allowUnauthorizedCerts: false,
}),
getNode: jest.fn(),
getNodeParameter: jest.fn().mockReturnValue('basicAuth'),
};
beforeEach(() => {
jest.clearAllMocks();
mockFunctions.getNodeParameter.mockReturnValue('basicAuth');
mockFunctions.getCredentials.mockResolvedValue({
url: 'http://example.com',
allowUnauthorizedCerts: false,
});
});
describe('wordpressApiRequest', () => {
@@ -26,6 +32,20 @@ describe('Wordpress > GenericFunctions', () => {
expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalled();
});
it('should use wordpressApi credential for basic auth', async () => {
mockFunctions.helpers.requestWithAuthentication.mockResolvedValue({ data: 'testData' });
await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {});
expect(mockFunctions.getCredentials).toHaveBeenCalledWith('wordpressApi');
expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'wordpressApi',
expect.objectContaining({
uri: 'http://example.com/wp-json/wp/v2/posts',
rejectUnauthorized: true,
}),
);
});
it('should throw NodeApiError on failure', async () => {
mockFunctions.helpers.requestWithAuthentication.mockRejectedValue({ message: 'fail' });
await expect(
@@ -34,6 +54,61 @@ describe('Wordpress > GenericFunctions', () => {
});
});
describe('wordpressApiRequest with OAuth2', () => {
beforeEach(() => {
mockFunctions.getNodeParameter.mockReturnValue('oAuth2');
mockFunctions.getCredentials.mockResolvedValue({
customDomain: false,
customDomainUrl: '',
});
mockFunctions.helpers.requestWithAuthentication.mockResolvedValue({ data: 'testData' });
});
it('should use wordpressOAuth2Api credential', async () => {
await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {});
expect(mockFunctions.getCredentials).toHaveBeenCalledWith('wordpressOAuth2Api');
expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'wordpressOAuth2Api',
expect.objectContaining({ method: 'GET' }),
);
});
it('should use public-api.wordpress.com base URL without custom domain', async () => {
await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {});
expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'wordpressOAuth2Api',
expect.objectContaining({
uri: 'https://public-api.wordpress.com/wp/v2/posts',
}),
);
});
it('should include site prefix when custom domain is set', async () => {
mockFunctions.getCredentials.mockResolvedValue({
customDomain: true,
customDomainUrl: 'myblog.com',
});
await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {});
expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'wordpressOAuth2Api',
expect.objectContaining({
uri: 'https://public-api.wordpress.com/wp/v2/sites/myblog.com/posts',
}),
);
});
it('should not set rejectUnauthorized for OAuth2 path', async () => {
await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {});
const callArgs = mockFunctions.helpers.requestWithAuthentication.mock.calls[0][1];
expect(callArgs).not.toHaveProperty('rejectUnauthorized');
});
});
describe('wordpressApiRequestAllItems', () => {
it('should accumulate all pages', async () => {
mockFunctions.helpers.requestWithAuthentication

View File

@@ -397,6 +397,7 @@
"dist/credentials/WiseApi.credentials.js",
"dist/credentials/WooCommerceApi.credentials.js",
"dist/credentials/WordpressApi.credentials.js",
"dist/credentials/WordpressOAuth2Api.credentials.js",
"dist/credentials/WorkableApi.credentials.js",
"dist/credentials/WufooApi.credentials.js",
"dist/credentials/XeroOAuth2Api.credentials.js",