Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
Comment thread
Joffcom marked this conversation as resolved.

export class WordpressOAuth2Api implements ICredentialType {
Comment thread
Joffcom marked this conversation as resolved.
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: '',
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
},
{
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"}}',
},
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { ClientOAuth2 } from '@n8n/client-oauth2';
Comment thread
Joffcom marked this conversation as resolved.
Outdated
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');
});
});
});
27 changes: 23 additions & 4 deletions packages/nodes-base/nodes/Wordpress/GenericFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Comment thread
Joffcom marked this conversation as resolved.
Outdated
} 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: {
Expand All @@ -29,16 +49,15 @@ 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);
if (Object.keys(options.body as IDataObject).length === 0) {
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);
Expand Down
31 changes: 31 additions & 0 deletions packages/nodes-base/nodes/Wordpress/Wordpress.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading