feat(Wordpress Node): Add support for OAuth2
This commit is contained in:
@@ -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"}}',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user