node-add-oauth

Pass

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.

@n8n-io
NOASSERTION3/27/2026
66out of 100
(0)
181.3k
3
5

Install Skill

Skills are third-party code from public GitHub repositories. SkillHub scans for known malicious patterns but cannot guarantee safety. Review the source code before installing.

Install globally (user-level):

npx skillhub install n8n-io/n8n/node-add-oauth

Install in current project:

npx skillhub install n8n-io/n8n/node-add-oauth --project

Suggested path: ~/.claude/skills/node-add-oauth/

AI Review

Instruction Quality78
Description Precision58
Usefulness56
Technical Soundness72

Scored 66 — exceptionally well-structured contributing skill with 10 clear implementation steps, TypeScript templates, and test requirements. One of the best contributing skills seen — high IQ (78) but generality limited to n8n repo only.

SKILL.md Content

---
name: node-add-oauth
description: 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.
argument-hint: "[node-name] [optional: custom-scopes flag or scope list]"
---

## Overview

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`).

---

## Step 0 — Parse arguments

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?"

---

## Step 1 — Explore the node

Read the following (adjust path conventions for the specific service):

1. Node directory: `packages/nodes-base/nodes/{NODE_NAME}/`
   - Find `*.node.ts` (main node) and any `*Trigger.node.ts`
   - Find `GenericFunctions.ts` (may be named differently)
   - Check if an `auth` / `version` subdirectory exists
2. Existing credentials: `packages/nodes-base/credentials/` — look for existing
   `{NODE_NAME}*Api.credentials.ts` files to understand the naming convention and any
   auth method already in use.
3. `package.json` at `packages/nodes-base/package.json` — find where existing credentials
   for this node are registered (grep for the node name).

---

## Step 2 — Research OAuth2 endpoints

Look up the service's OAuth2 documentation:
- Authorization URL
- Access Token URL
- Required auth query parameters (e.g. `prompt=consent`, `access_type=offline`)
- Default scopes needed for the node's existing operations
- Whether the API requires a cloudId / workspace ID lookup after the token exchange
  (Atlassian-style gateway APIs do; most services don't)

If you can't determine the endpoints confidently, ask the user to provide them.

---

## Step 3 — Create the credential file

File: `packages/nodes-base/credentials/{NODE_NAME}OAuth2Api.credentials.ts`

```typescript
import type { ICredentialType, INodeProperties } from 'n8n-workflow';

const defaultScopes = [/* minimum scopes for existing node operations */];

export class {NODE_NAME}OAuth2Api implements ICredentialType {
	name = '{camelCase}OAuth2Api';
	extends = ['oAuth2Api'];
	displayName = '{Display Name} OAuth2 API';
	documentationUrl = '{doc-slug}'; // matches docs.n8n.io/integrations/...

	properties: INodeProperties[] = [
		// Include service-specific fields the node needs to construct API calls
		// (e.g. domain, workspace URL) — add BEFORE the hidden fields below.

		{ displayName: 'Grant Type',        name: 'grantType',      type: 'hidden', default: 'authorizationCode' },
		{ displayName: 'Authorization URL', name: 'authUrl',        type: 'hidden', default: '{AUTH_URL}', required: true },
		{ displayName: 'Access Token URL',  name: 'accessTokenUrl', type: 'hidden', default: '{TOKEN_URL}', required: true },
		// Only include authQueryParameters if the service requires extra query params:
		{ displayName: 'Auth URI Query Parameters', name: 'authQueryParameters', type: 'hidden', default: '{QUERY_PARAMS}' },
		{ displayName: 'Authentication',    name: 'authentication', type: 'hidden', default: 'header' },

		// ── Custom scopes block (ONLY when CUSTOM_SCOPES = yes) ──────────────
		{
			displayName: 'Custom Scopes',
			name: 'customScopes',
			type: 'boolean',
			default: false,
			description: 'Define custom scopes',
		},
		{
			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: defaultScopes.join(' '),
			description: 'Scopes that should be enabled',
		},
		// ── End custom scopes block ───────────────────────────────────────────

		{
			displayName: 'Scope',
			name: 'scope',
			type: 'hidden',
			// Custom scopes: expression toggles between user value and defaults.
			// Fixed scopes: use the literal defaultScopes string instead.
			default:
				'={{$self["customScopes"] ? $self["enabledScopes"] : "' + defaultScopes.join(' ') + '"}}',
		},
	];
}
```

**Rules:**
- No `authenticate` block — `oAuth2Api` machinery handles Bearer token injection automatically.
- No `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.
- If the service needs a domain / workspace URL for API call construction, add it as a
  visible `string` field **before** the hidden fields.

---

## Step 4 — Register the credential in `package.json`

File: `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):

```json
"dist/credentials/{NODE_NAME}OAuth2Api.credentials.js",
```

---

## Step 5 — Update `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.

```typescript
export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [
	'oAuth2Api',
	'googleOAuth2Api',
	'microsoftOAuth2Api',
	'highLevelOAuth2Api',
	'mcpOAuth2Api',
	'{camelCase}OAuth2Api', // ← add this
];
```

---

## Step 6 — Update `GenericFunctions.ts`

### 6a — Standard services (token works directly against the instance URL)

Add an `else if` branch before the existing `else` fallback:

```typescript
} else if ({versionParam} === '{camelCase}OAuth2') {
	domain = (await this.getCredentials('{camelCase}OAuth2Api')).{domainField} as string;
	credentialType = '{camelCase}OAuth2Api';
} else {
```

### 6b — Gateway services requiring a workspace/cloud ID lookup

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:

```typescript
// 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;
}
```

Then in the main request function:

```typescript
} 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 {
```

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.

---

## Step 7 — Update the node file(s)

### Main node (`*.node.ts`)

**Credentials array** — add an entry for the new credential type:

```typescript
{
	name: '{camelCase}OAuth2Api',
	required: true,
	displayOptions: { show: { {versionParam}: ['{camelCase}OAuth2'] } },
},
```

**Version/auth options** — add to the `{versionParam}` (or equivalent) options list:

```typescript
{ name: '{Display Name} (OAuth2)', value: '{camelCase}OAuth2' },
```

Keep `default` unchanged — existing workflows must not be affected.

### Trigger node (`*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.

---

## Step 8 — Write credential tests

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:
1. **Metadata** — name, extends array, `enabledScopes` default, auth URL, token URL,
   `authQueryParameters` default (if applicable).
2. **Default scopes in authorization URI** — call `oauthClient.code.getUri()`, assert each
   default scope is present.
3. **Token retrieval with default scopes** — mock the token endpoint with `nock`, call
   `oauthClient.code.getToken(...)`, assert `token.data.scope` contains each scope.
4. **Custom scopes in authorization URI** _(skip when CUSTOM_SCOPES = no)_.
5. **Token retrieval with custom scopes** _(skip when CUSTOM_SCOPES = no)_.
6. **Minimal / different scope set** _(skip when CUSTOM_SCOPES = no)_ — assert scopes not
   in the set are absent from both the URI and token response.

Lifecycle hooks required:
```typescript
beforeAll(() => { nock.disableNetConnect(); });
afterAll(() => { nock.restore(); });
afterEach(() => { nock.cleanAll(); });
```

---

## Step 9 — Update `GenericFunctions.test.ts`

In the credential-routing `describe` block:

1. If a site-ID cache (`_cloudIdCache`) was added, import it and call
   `_cloudIdCache.clear()` (or equivalent) in `afterEach`.
2. Add/update the OAuth2 routing test case:
   - **Simple routing**: assert `getCredentials` was called with the correct credential
     name and `requestWithAuthentication` was called with the correct name and URI.
   - **Gateway lookup**: mock `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.

---

## Step 10 — Verify

```bash
# 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
```

Fix any type errors before finishing. Never skip `pnpm typecheck`.