diff --git a/.claude/skills/node-add-oauth/SKILL.md b/.claude/skills/node-add-oauth/SKILL.md new file mode 100644 index 00000000000..66a185bfce7 --- /dev/null +++ b/.claude/skills/node-add-oauth/SKILL.md @@ -0,0 +1,322 @@ +--- +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(); + +async function getSiteId( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, + credentialType: string, + domain: string, +): Promise { + 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`.