feat: Add cred-class-oauth2-naming ESLint rule for community nodes (no-changelog) (#29858)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garrit Franke 2026-05-06 11:37:52 +02:00 committed by GitHub
parent ff41613533
commit e99e6afb49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 388 additions and 0 deletions

View File

@ -48,6 +48,7 @@ export default [
| :--------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ | :--- | :--- | :- | :- | :- |
| [ai-node-package-json](docs/rules/ai-node-package-json.md) | Enforce consistency between n8n.aiNodeSdkVersion and ai-node-sdk peer dependency in community node packages | ✅ ☑️ | | | | |
| [cred-class-field-icon-missing](docs/rules/cred-class-field-icon-missing.md) | Credential class must have an `icon` property defined | ✅ ☑️ | | | 💡 | |
| [cred-class-oauth2-naming](docs/rules/cred-class-oauth2-naming.md) | OAuth2 credentials must include `OAuth2` in the class name, `name`, and `displayName` | ✅ ☑️ | | 🔧 | | |
| [credential-documentation-url](docs/rules/credential-documentation-url.md) | Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug) | ✅ ☑️ | | 🔧 | | |
| [credential-password-field](docs/rules/credential-password-field.md) | Ensure credential fields with sensitive names have typeOptions.password = true | ✅ ☑️ | | 🔧 | | |
| [credential-test-required](docs/rules/credential-test-required.md) | Ensure credentials have a credential test | ✅ ☑️ | | | 💡 | |

View File

@ -0,0 +1,68 @@
# OAuth2 credentials must include `OAuth2` in the class name, `name`, and `displayName` (`@n8n/community-nodes/cred-class-oauth2-naming`)
💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`.
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
<!-- end auto-generated rule header -->
## Rule Details
OAuth2 credentials must consistently identify themselves as OAuth2 across all three naming surfaces. This makes them easy to distinguish from other auth flavours (API key, basic auth) in code, in import statements, and in the credentials picker.
A credential class is considered an OAuth2 credential if **any** of the following are true:
- The class name contains `OAuth2` (e.g. `GoogleOAuth2Api`).
- The class TypeScript-extends a base whose name contains `OAuth2` (e.g. `class GoogleOAuth2Api extends OAuth2Api`).
- The `extends` class field references an `OAuth2` credential (e.g. `extends = ['oAuth2Api']`).
- The `name` or `displayName` field contains `OAuth2`.
When a credential is detected as OAuth2, all of the following must contain `OAuth2`:
- Class name (must end with `OAuth2Api`).
- `name` class field.
- `displayName` class field.
The class name is auto-fixable; `name` and `displayName` must be updated manually because their casing convention varies.
## Examples
### ❌ Incorrect
```typescript
// Class name detected as OAuth2 but `name` and `displayName` lack OAuth2.
export class GoogleOAuth2Api implements ICredentialType {
name = 'googleApi';
displayName = 'Google API';
properties: INodeProperties[] = [];
}
```
```typescript
// Extends OAuth2 base, but neither class name, `name`, nor `displayName` reflect that.
export class GoogleApi implements ICredentialType {
name = 'googleApi';
displayName = 'Google API';
extends = ['oAuth2Api'];
properties: INodeProperties[] = [];
}
```
### ✅ Correct
```typescript
export class GoogleOAuth2Api implements ICredentialType {
name = 'googleOAuth2Api';
displayName = 'Google OAuth2 API';
extends = ['oAuth2Api'];
properties: INodeProperties[] = [];
}
```
## Migrated from
This rule consolidates three rules from the legacy `eslint-plugin-n8n-nodes-base` plugin into a single conceptual check:
- `cred-class-name-missing-oauth2-suffix`
- `cred-class-field-name-missing-oauth2`
- `cred-class-field-display-name-missing-oauth2`

View File

@ -40,6 +40,7 @@ const configs = {
'@n8n/community-nodes/resource-operation-pattern': 'warn',
'@n8n/community-nodes/credential-documentation-url': 'error',
'@n8n/community-nodes/cred-class-field-icon-missing': 'error',
'@n8n/community-nodes/cred-class-oauth2-naming': 'error',
'@n8n/community-nodes/node-connection-type-literal': 'error',
'@n8n/community-nodes/missing-paired-item': 'error',
'@n8n/community-nodes/node-operation-error-itemindex': 'error',
@ -76,6 +77,7 @@ const configs = {
'@n8n/community-nodes/credential-documentation-url': 'error',
'@n8n/community-nodes/resource-operation-pattern': 'warn',
'@n8n/community-nodes/cred-class-field-icon-missing': 'error',
'@n8n/community-nodes/cred-class-oauth2-naming': 'error',
'@n8n/community-nodes/node-connection-type-literal': 'error',
'@n8n/community-nodes/missing-paired-item': 'error',
'@n8n/community-nodes/node-operation-error-itemindex': 'error',

View File

@ -0,0 +1,197 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { CredClassOAuth2NamingRule } from './cred-class-oauth2-naming.js';
const ruleTester = new RuleTester();
const credFilePath = '/tmp/TestCredential.credentials.ts';
const nonCredFilePath = '/tmp/SomeHelper.ts';
type CredentialFields = {
className: string;
name?: string;
displayName?: string;
extendsValues?: string[];
superClass?: string;
};
function createCredentialCode(fields: CredentialFields): string {
const { className, name, displayName, extendsValues, superClass } = fields;
const heritage = superClass ? ` extends ${superClass}` : '';
const lines: string[] = [];
if (name !== undefined) lines.push(`\tname = '${name}';`);
if (displayName !== undefined) lines.push(`\tdisplayName = '${displayName}';`);
if (extendsValues !== undefined) {
const arr = extendsValues.map((v) => `'${v}'`).join(', ');
lines.push(`\textends = [${arr}];`);
}
lines.push('\tproperties: INodeProperties[] = [];');
return `
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
export class ${className}${heritage} implements ICredentialType {
${lines.join('\n')}
}`;
}
function createRegularClass(): string {
return `
export class SomeHelper {
name = 'helper';
}`;
}
ruleTester.run('cred-class-oauth2-naming', CredClassOAuth2NamingRule, {
valid: [
{
name: 'non-OAuth2 credential is ignored',
filename: credFilePath,
code: createCredentialCode({
className: 'GoogleApi',
name: 'googleApi',
displayName: 'Google API',
}),
},
{
name: 'OAuth2 credential with all naming correct',
filename: credFilePath,
code: createCredentialCode({
className: 'GoogleOAuth2Api',
name: 'googleOAuth2Api',
displayName: 'Google OAuth2 API',
}),
},
{
name: 'OAuth2 credential detected via extends array, all naming correct',
filename: credFilePath,
code: createCredentialCode({
className: 'GoogleOAuth2Api',
name: 'googleOAuth2Api',
displayName: 'Google OAuth2 API',
extendsValues: ['oAuth2Api'],
}),
},
{
name: 'OAuth2 credential detected via TS superClass, all naming correct',
filename: credFilePath,
code: createCredentialCode({
className: 'GoogleOAuth2Api',
name: 'googleOAuth2Api',
displayName: 'Google OAuth2 API',
superClass: 'OAuth2Api',
}),
},
{
name: 'class not implementing ICredentialType is ignored',
filename: credFilePath,
code: createRegularClass(),
},
{
name: 'non-.credentials.ts file is ignored',
filename: nonCredFilePath,
code: createCredentialCode({
className: 'GoogleApi',
name: 'googleOAuth2Api',
displayName: 'Google OAuth2 API',
}),
},
],
invalid: [
{
name: 'class name missing OAuth2 (detected via name field), with Api suffix',
filename: credFilePath,
code: createCredentialCode({
className: 'GoogleApi',
name: 'googleOAuth2Api',
displayName: 'Google OAuth2 API',
}),
errors: [{ messageId: 'classNameMissingOAuth2', data: { name: 'GoogleApi' } }],
output: createCredentialCode({
className: 'GoogleOAuth2Api',
name: 'googleOAuth2Api',
displayName: 'Google OAuth2 API',
}),
},
{
name: 'class name missing OAuth2 (detected via displayName), no Api suffix',
filename: credFilePath,
code: createCredentialCode({
className: 'Google',
name: 'googleOAuth2Api',
displayName: 'Google OAuth2 API',
}),
errors: [{ messageId: 'classNameMissingOAuth2', data: { name: 'Google' } }],
output: createCredentialCode({
className: 'GoogleOAuth2Api',
name: 'googleOAuth2Api',
displayName: 'Google OAuth2 API',
}),
},
{
name: 'name field missing OAuth2 (detected via class name)',
filename: credFilePath,
code: createCredentialCode({
className: 'GoogleOAuth2Api',
name: 'googleApi',
displayName: 'Google OAuth2 API',
}),
errors: [{ messageId: 'nameMissingOAuth2', data: { value: 'googleApi' } }],
output: null,
},
{
name: 'displayName missing OAuth2 (detected via class name)',
filename: credFilePath,
code: createCredentialCode({
className: 'GoogleOAuth2Api',
name: 'googleOAuth2Api',
displayName: 'Google API',
}),
errors: [{ messageId: 'displayNameMissingOAuth2', data: { value: 'Google API' } }],
output: null,
},
{
name: 'all three missing OAuth2, detected via extends array',
filename: credFilePath,
code: createCredentialCode({
className: 'GoogleApi',
name: 'googleApi',
displayName: 'Google API',
extendsValues: ['oAuth2Api'],
}),
errors: [
{ messageId: 'classNameMissingOAuth2', data: { name: 'GoogleApi' } },
{ messageId: 'nameMissingOAuth2', data: { value: 'googleApi' } },
{ messageId: 'displayNameMissingOAuth2', data: { value: 'Google API' } },
],
output: createCredentialCode({
className: 'GoogleOAuth2Api',
name: 'googleApi',
displayName: 'Google API',
extendsValues: ['oAuth2Api'],
}),
},
{
name: 'all three missing OAuth2, detected via TS superClass extends',
filename: credFilePath,
code: createCredentialCode({
className: 'GoogleApi',
name: 'googleApi',
displayName: 'Google API',
superClass: 'OAuth2Api',
}),
errors: [
{ messageId: 'classNameMissingOAuth2', data: { name: 'GoogleApi' } },
{ messageId: 'nameMissingOAuth2', data: { value: 'googleApi' } },
{ messageId: 'displayNameMissingOAuth2', data: { value: 'Google API' } },
],
output: createCredentialCode({
className: 'GoogleOAuth2Api',
name: 'googleApi',
displayName: 'Google API',
superClass: 'OAuth2Api',
}),
},
],
});

View File

@ -0,0 +1,118 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import {
createRule,
findClassProperty,
getStringLiteralValue,
isCredentialTypeClass,
isFileType,
} from '../utils/index.js';
const OAUTH2_PATTERN = /oauth2/i;
function containsOAuth2(value: string): boolean {
return OAUTH2_PATTERN.test(value);
}
function getExtendsArrayValues(node: TSESTree.PropertyDefinition): string[] {
if (node.value?.type !== AST_NODE_TYPES.ArrayExpression) return [];
const values: string[] = [];
for (const element of node.value.elements) {
const value = element ? getStringLiteralValue(element) : null;
if (value !== null) values.push(value);
}
return values;
}
function suggestOAuth2ApiName(className: string): string {
if (className.endsWith('Api')) {
return `${className.slice(0, -'Api'.length)}OAuth2Api`;
}
return `${className}OAuth2Api`;
}
export const CredClassOAuth2NamingRule = createRule({
name: 'cred-class-oauth2-naming',
meta: {
type: 'problem',
docs: {
description:
'OAuth2 credentials must include `OAuth2` in the class name, `name`, and `displayName`',
},
messages: {
classNameMissingOAuth2: "OAuth2 credential class name '{{name}}' must end with 'OAuth2Api'",
nameMissingOAuth2: "OAuth2 credential `name` field '{{value}}' must contain 'OAuth2'",
displayNameMissingOAuth2:
"OAuth2 credential `displayName` field '{{value}}' must contain 'OAuth2'",
},
schema: [],
fixable: 'code',
},
defaultOptions: [],
create(context) {
if (!isFileType(context.filename, '.credentials.ts')) {
return {};
}
return {
ClassDeclaration(node) {
if (!isCredentialTypeClass(node)) return;
if (!node.id) return;
const className = node.id.name;
const superClassName =
node.superClass?.type === AST_NODE_TYPES.Identifier ? node.superClass.name : null;
const nameProperty = findClassProperty(node, 'name');
const nameValue = nameProperty?.value ? getStringLiteralValue(nameProperty.value) : null;
const displayNameProperty = findClassProperty(node, 'displayName');
const displayNameValue = displayNameProperty?.value
? getStringLiteralValue(displayNameProperty.value)
: null;
const extendsProperty = findClassProperty(node, 'extends');
const extendsValues = extendsProperty ? getExtendsArrayValues(extendsProperty) : [];
const isOAuth2Credential =
containsOAuth2(className) ||
(superClassName !== null && containsOAuth2(superClassName)) ||
extendsValues.some(containsOAuth2) ||
(nameValue !== null && containsOAuth2(nameValue)) ||
(displayNameValue !== null && containsOAuth2(displayNameValue));
if (!isOAuth2Credential) return;
if (!className.endsWith('OAuth2Api')) {
const fixedClassName = suggestOAuth2ApiName(className);
context.report({
node: node.id,
messageId: 'classNameMissingOAuth2',
data: { name: className },
fix(fixer) {
return fixer.replaceText(node.id!, fixedClassName);
},
});
}
if (nameValue !== null && !containsOAuth2(nameValue)) {
context.report({
node: nameProperty!.value!,
messageId: 'nameMissingOAuth2',
data: { value: nameValue },
});
}
if (displayNameValue !== null && !containsOAuth2(displayNameValue)) {
context.report({
node: displayNameProperty!.value!,
messageId: 'displayNameMissingOAuth2',
data: { value: displayNameValue },
});
}
},
};
},
});

View File

@ -2,6 +2,7 @@ import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint';
import { AiNodePackageJsonRule } from './ai-node-package-json.js';
import { CredClassFieldIconMissingRule } from './cred-class-field-icon-missing.js';
import { CredClassOAuth2NamingRule } from './cred-class-oauth2-naming.js';
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
import { CredentialPasswordFieldRule } from './credential-password-field.js';
import { CredentialTestRequiredRule } from './credential-test-required.js';
@ -53,6 +54,7 @@ export const rules = {
'credential-documentation-url': CredentialDocumentationUrlRule,
'node-class-description-icon-missing': NodeClassDescriptionIconMissingRule,
'cred-class-field-icon-missing': CredClassFieldIconMissingRule,
'cred-class-oauth2-naming': CredClassOAuth2NamingRule,
'node-connection-type-literal': NodeConnectionTypeLiteralRule,
'node-operation-error-itemindex': NodeOperationErrorItemIndexRule,
'missing-paired-item': MissingPairedItemRule,