npx skills add ...
npx skills add n8n-io/n8n --skill node-add-oauth
npx skills add n8n-io/n8n --skill node-add-oauth
Add OAuth2 credential support to an existing n8n node — creates the credential file, updates the node, adds tests, and keeps the CLI constant in sync. Use when the user says /node-add-oauth.
Add OAuth2 (Authorization Code / 3LO) support to an existing n8n node. Works for any third-party service that supports standard OAuth2.
Before starting, read comparable existing OAuth2 credential files and tests under
packages/nodes-base/credentials/ to understand the conventions used in this codebase
(e.g. DiscordOAuth2Api.credentials.ts, MicrosoftTeamsOAuth2Api.credentials.ts).
Extract:
NODE_NAME: the service name (e.g. GitHub, Notion). Try to infer from the argument;
if ambiguous, ask the user.CUSTOM_SCOPES: whether the credential should support user-defined scopes. If the
argument does not make this clear, ask the user before proceeding:
"Should users be able to customise the OAuth2 scopes for this credential, or should scopes be fixed?"
Read the following (adjust path conventions for the specific service):
packages/nodes-base/nodes/{NODE_NAME}/
*.node.ts (main node) and any *Trigger.node.tsGenericFunctions.ts (may be named differently)auth / version subdirectory existspackages/nodes-base/credentials/ — look for existing
{NODE_NAME}*Api.credentials.ts files to understand the naming convention and any
auth method already in use.package.json at packages/nodes-base/package.json — find where existing credentials
for this node are registered (grep for the node name).Look up the service's OAuth2 documentation:
prompt=consent, access_type=offline)If you can't determine the endpoints confidently, ask the user to provide them.
File: packages/nodes-base/credentials/{NODE_NAME}OAuth2Api.credentials.ts
Rules:
authenticate block — oAuth2Api machinery handles Bearer token injection automatically.test block — the OAuth dance validates the credential.defaultScopes at module level is the single source of truth: it populates both the
enabledScopes default and the scope expression fallback. Update it in one place.string field before the hidden fields.package.jsonFile: packages/nodes-base/package.json
Find the n8n.credentials array and insert the new entry near other credentials for this
service (alphabetical ordering within the service's block):
GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE (custom scopes only)Only do this step when CUSTOM_SCOPES = yes.
File: packages/cli/src/constants.ts
Add '{camelCase}OAuth2Api' to the GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE
array. Without this, n8n deletes the user's custom scope on OAuth2 reconnect.
GenericFunctions.tsAdd an else if branch before the existing else fallback:
When the OAuth token is scoped for a gateway URL rather than the direct instance URL
(Atlassian's api.atlassian.com is the canonical example), add a module-level cache and
lookup helper before the main request function:
Then in the main request function:
The existing uri: \${domain}/rest${endpoint}`` construction then produces the correct
gateway URL automatically.
Add NodeOperationError to the n8n-workflow import if not already present.
*.node.ts)Credentials array — add an entry for the new credential type:
Version/auth options — add to the {versionParam} (or equivalent) options list:
Keep default unchanged — existing workflows must not be affected.
*Trigger.node.ts, if present)Same two changes. Preserve any displayName label pattern already used by other credential
entries in that trigger node's credentials array.
File: packages/nodes-base/credentials/test/{NODE_NAME}OAuth2Api.credentials.test.ts
Use ClientOAuth2 from @n8n/client-oauth2 and nock for HTTP mocking. Follow the
structure in MicrosoftTeamsOAuth2Api.credentials.test.ts.
Required test cases:
enabledScopes default, auth URL, token URL,
authQueryParameters default (if applicable).oauthClient.code.getUri(), assert each
default scope is present.nock, call
oauthClient.code.getToken(...), assert token.data.scope contains each scope.Lifecycle hooks required:
GenericFunctions.test.tsIn the credential-routing describe block:
_cloudIdCache) was added, import it and call
_cloudIdCache.clear() (or equivalent) in afterEach.getCredentials was called with the correct credential
name and requestWithAuthentication was called with the correct name and URI.requestWithAuthentication to return the accessible-resources
payload on the first call and {} on the second. Assert the first call targets the
resources endpoint and the second call uses the gateway base URL with the site ID.Fix any type errors before finishing. Never skip pnpm typecheck.
"dist/credentials/{NODE_NAME}OAuth2Api.credentials.js",export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [
'oAuth2Api',
'googleOAuth2Api',
'microsoftOAuth2Api',
'highLevelOAuth2Api',
'mcpOAuth2Api',
'{camelCase}OAuth2Api', // ← add this
];} else if ({versionParam} === '{camelCase}OAuth2') {
domain = (await this.getCredentials('{camelCase}OAuth2Api')).{domainField} as string;
credentialType = '{camelCase}OAuth2Api';
} else {// Module-level cache: normalised domain → site/cloud ID
export const _cloudIdCache = new Map<string, string>();
async function getSiteId(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
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: '{ACCESSIBLE_RESOURCES_ENDPOINT}',
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 site found for domain: ${domain}. Make sure the domain matches your site URL exactly.`,
);
}
_cloudIdCache.set(normalizedDomain, site.id);
return site.id;
}} else if ({versionParam} === '{camelCase}OAuth2') {
const rawDomain = (await this.getCredentials('{camelCase}OAuth2Api')).domain as string;
credentialType = '{camelCase}OAuth2Api';
const siteId = await getSiteId.call(this, credentialType, rawDomain);
domain = `{GATEWAY_BASE_URL}/${siteId}`;
} else {{
name: '{camelCase}OAuth2Api',
required: true,
displayOptions: { show: { {versionParam}: ['{camelCase}OAuth2'] } },
},{ name: '{Display Name} (OAuth2)', value: '{camelCase}OAuth2' },beforeAll(() => { nock.disableNetConnect(); });
afterAll(() => { nock.restore(); });
afterEach(() => { nock.cleanAll(); });# From packages/nodes-base/
pnpm test credentials/test/{NODE_NAME}OAuth2Api.credentials.test.ts
pnpm test nodes/{NODE_NAME}/__test__/GenericFunctions.test.ts
pnpm typecheck
pnpm lint
# Only when constants.ts was changed:
pushd ../cli && pnpm typecheck && popd