|
| 1 | +--- |
| 2 | +name: node-add-oauth |
| 3 | +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. |
| 4 | +argument-hint: "[node-name] [optional: custom-scopes flag or scope list]" |
| 5 | +--- |
| 6 | + |
| 7 | +## Overview |
| 8 | + |
| 9 | +Add OAuth2 (Authorization Code / 3LO) support to an existing n8n node. Works for any |
| 10 | +third-party service that supports standard OAuth2. |
| 11 | + |
| 12 | +Before starting, read comparable existing OAuth2 credential files and tests under |
| 13 | +`packages/nodes-base/credentials/` to understand the conventions used in this codebase |
| 14 | +(e.g. `DiscordOAuth2Api.credentials.ts`, `MicrosoftTeamsOAuth2Api.credentials.ts`). |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Step 0 — Parse arguments |
| 19 | + |
| 20 | +Extract: |
| 21 | +- `NODE_NAME`: the service name (e.g. `GitHub`, `Notion`). Try to infer from the argument; |
| 22 | + if ambiguous, ask the user. |
| 23 | +- `CUSTOM_SCOPES`: whether the credential should support user-defined scopes. If the |
| 24 | + argument does not make this clear, **ask the user** before proceeding: |
| 25 | + > "Should users be able to customise the OAuth2 scopes for this credential, or should |
| 26 | + > scopes be fixed?" |
| 27 | +
|
| 28 | +--- |
| 29 | + |
| 30 | +## Step 1 — Explore the node |
| 31 | + |
| 32 | +Read the following (adjust path conventions for the specific service): |
| 33 | + |
| 34 | +1. Node directory: `packages/nodes-base/nodes/{NODE_NAME}/` |
| 35 | + - Find `*.node.ts` (main node) and any `*Trigger.node.ts` |
| 36 | + - Find `GenericFunctions.ts` (may be named differently) |
| 37 | + - Check if an `auth` / `version` subdirectory exists |
| 38 | +2. Existing credentials: `packages/nodes-base/credentials/` — look for existing |
| 39 | + `{NODE_NAME}*Api.credentials.ts` files to understand the naming convention and any |
| 40 | + auth method already in use. |
| 41 | +3. `package.json` at `packages/nodes-base/package.json` — find where existing credentials |
| 42 | + for this node are registered (grep for the node name). |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +## Step 2 — Research OAuth2 endpoints |
| 47 | + |
| 48 | +Look up the service's OAuth2 documentation: |
| 49 | +- Authorization URL |
| 50 | +- Access Token URL |
| 51 | +- Required auth query parameters (e.g. `prompt=consent`, `access_type=offline`) |
| 52 | +- Default scopes needed for the node's existing operations |
| 53 | +- Whether the API requires a cloudId / workspace ID lookup after the token exchange |
| 54 | + (Atlassian-style gateway APIs do; most services don't) |
| 55 | + |
| 56 | +If you can't determine the endpoints confidently, ask the user to provide them. |
| 57 | + |
| 58 | +--- |
| 59 | + |
| 60 | +## Step 3 — Create the credential file |
| 61 | + |
| 62 | +File: `packages/nodes-base/credentials/{NODE_NAME}OAuth2Api.credentials.ts` |
| 63 | + |
| 64 | +```typescript |
| 65 | +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; |
| 66 | + |
| 67 | +const defaultScopes = [/* minimum scopes for existing node operations */]; |
| 68 | + |
| 69 | +export class {NODE_NAME}OAuth2Api implements ICredentialType { |
| 70 | + name = '{camelCase}OAuth2Api'; |
| 71 | + extends = ['oAuth2Api']; |
| 72 | + displayName = '{Display Name} OAuth2 API'; |
| 73 | + documentationUrl = '{doc-slug}'; // matches docs.n8n.io/integrations/... |
| 74 | + |
| 75 | + properties: INodeProperties[] = [ |
| 76 | + // Include service-specific fields the node needs to construct API calls |
| 77 | + // (e.g. domain, workspace URL) — add BEFORE the hidden fields below. |
| 78 | + |
| 79 | + { displayName: 'Grant Type', name: 'grantType', type: 'hidden', default: 'authorizationCode' }, |
| 80 | + { displayName: 'Authorization URL', name: 'authUrl', type: 'hidden', default: '{AUTH_URL}', required: true }, |
| 81 | + { displayName: 'Access Token URL', name: 'accessTokenUrl', type: 'hidden', default: '{TOKEN_URL}', required: true }, |
| 82 | + // Only include authQueryParameters if the service requires extra query params: |
| 83 | + { displayName: 'Auth URI Query Parameters', name: 'authQueryParameters', type: 'hidden', default: '{QUERY_PARAMS}' }, |
| 84 | + { displayName: 'Authentication', name: 'authentication', type: 'hidden', default: 'header' }, |
| 85 | + |
| 86 | + // ── Custom scopes block (ONLY when CUSTOM_SCOPES = yes) ────────────── |
| 87 | + { |
| 88 | + displayName: 'Custom Scopes', |
| 89 | + name: 'customScopes', |
| 90 | + type: 'boolean', |
| 91 | + default: false, |
| 92 | + description: 'Define custom scopes', |
| 93 | + }, |
| 94 | + { |
| 95 | + displayName: |
| 96 | + 'The default scopes needed for the node to work are already set. If you change these the node may not function correctly.', |
| 97 | + name: 'customScopesNotice', |
| 98 | + type: 'notice', |
| 99 | + default: '', |
| 100 | + displayOptions: { show: { customScopes: [true] } }, |
| 101 | + }, |
| 102 | + { |
| 103 | + displayName: 'Enabled Scopes', |
| 104 | + name: 'enabledScopes', |
| 105 | + type: 'string', |
| 106 | + displayOptions: { show: { customScopes: [true] } }, |
| 107 | + default: defaultScopes.join(' '), |
| 108 | + description: 'Scopes that should be enabled', |
| 109 | + }, |
| 110 | + // ── End custom scopes block ─────────────────────────────────────────── |
| 111 | + |
| 112 | + { |
| 113 | + displayName: 'Scope', |
| 114 | + name: 'scope', |
| 115 | + type: 'hidden', |
| 116 | + // Custom scopes: expression toggles between user value and defaults. |
| 117 | + // Fixed scopes: use the literal defaultScopes string instead. |
| 118 | + default: |
| 119 | + '={{$self["customScopes"] ? $self["enabledScopes"] : "' + defaultScopes.join(' ') + '"}}', |
| 120 | + }, |
| 121 | + ]; |
| 122 | +} |
| 123 | +``` |
| 124 | + |
| 125 | +**Rules:** |
| 126 | +- No `authenticate` block — `oAuth2Api` machinery handles Bearer token injection automatically. |
| 127 | +- No `test` block — the OAuth dance validates the credential. |
| 128 | +- `defaultScopes` at module level is the single source of truth: it populates both the |
| 129 | + `enabledScopes` default and the `scope` expression fallback. Update it in one place. |
| 130 | +- If the service needs a domain / workspace URL for API call construction, add it as a |
| 131 | + visible `string` field **before** the hidden fields. |
| 132 | + |
| 133 | +--- |
| 134 | + |
| 135 | +## Step 4 — Register the credential in `package.json` |
| 136 | + |
| 137 | +File: `packages/nodes-base/package.json` |
| 138 | + |
| 139 | +Find the `n8n.credentials` array and insert the new entry near other credentials for this |
| 140 | +service (alphabetical ordering within the service's block): |
| 141 | + |
| 142 | +```json |
| 143 | +"dist/credentials/{NODE_NAME}OAuth2Api.credentials.js", |
| 144 | +``` |
| 145 | + |
| 146 | +--- |
| 147 | + |
| 148 | +## Step 5 — Update `GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE` (custom scopes only) |
| 149 | + |
| 150 | +**Only do this step when CUSTOM_SCOPES = yes.** |
| 151 | + |
| 152 | +File: `packages/cli/src/constants.ts` |
| 153 | + |
| 154 | +Add `'{camelCase}OAuth2Api'` to the `GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE` |
| 155 | +array. Without this, n8n deletes the user's custom scope on OAuth2 reconnect. |
| 156 | + |
| 157 | +```typescript |
| 158 | +export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [ |
| 159 | + 'oAuth2Api', |
| 160 | + 'googleOAuth2Api', |
| 161 | + 'microsoftOAuth2Api', |
| 162 | + 'highLevelOAuth2Api', |
| 163 | + 'mcpOAuth2Api', |
| 164 | + '{camelCase}OAuth2Api', // ← add this |
| 165 | +]; |
| 166 | +``` |
| 167 | + |
| 168 | +--- |
| 169 | + |
| 170 | +## Step 6 — Update `GenericFunctions.ts` |
| 171 | + |
| 172 | +### 6a — Standard services (token works directly against the instance URL) |
| 173 | + |
| 174 | +Add an `else if` branch before the existing `else` fallback: |
| 175 | + |
| 176 | +```typescript |
| 177 | +} else if ({versionParam} === '{camelCase}OAuth2') { |
| 178 | + domain = (await this.getCredentials('{camelCase}OAuth2Api')).{domainField} as string; |
| 179 | + credentialType = '{camelCase}OAuth2Api'; |
| 180 | +} else { |
| 181 | +``` |
| 182 | +
|
| 183 | +### 6b — Gateway services requiring a workspace/cloud ID lookup |
| 184 | +
|
| 185 | +When the OAuth token is scoped for a gateway URL rather than the direct instance URL |
| 186 | +(Atlassian's `api.atlassian.com` is the canonical example), add a module-level cache and |
| 187 | +lookup helper **before** the main request function: |
| 188 | +
|
| 189 | +```typescript |
| 190 | +// Module-level cache: normalised domain → site/cloud ID |
| 191 | +export const _cloudIdCache = new Map<string, string>(); |
| 192 | + |
| 193 | +async function getSiteId( |
| 194 | + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, |
| 195 | + credentialType: string, |
| 196 | + domain: string, |
| 197 | +): Promise<string> { |
| 198 | + const normalizedDomain = domain.replace(/\/$/, ''); |
| 199 | + if (_cloudIdCache.has(normalizedDomain)) return _cloudIdCache.get(normalizedDomain)!; |
| 200 | + |
| 201 | + const resources = (await this.helpers.requestWithAuthentication.call(this, credentialType, { |
| 202 | + uri: '{ACCESSIBLE_RESOURCES_ENDPOINT}', |
| 203 | + json: true, |
| 204 | + })) as Array<{ id: string; url: string }>; |
| 205 | + |
| 206 | + const site = resources.find((r) => r.url === normalizedDomain); |
| 207 | + if (!site) { |
| 208 | + throw new NodeOperationError( |
| 209 | + this.getNode(), |
| 210 | + `No accessible site found for domain: ${domain}. Make sure the domain matches your site URL exactly.`, |
| 211 | + ); |
| 212 | + } |
| 213 | + |
| 214 | + _cloudIdCache.set(normalizedDomain, site.id); |
| 215 | + return site.id; |
| 216 | +} |
| 217 | +``` |
| 218 | +
|
| 219 | +Then in the main request function: |
| 220 | +
|
| 221 | +```typescript |
| 222 | +} else if ({versionParam} === '{camelCase}OAuth2') { |
| 223 | + const rawDomain = (await this.getCredentials('{camelCase}OAuth2Api')).domain as string; |
| 224 | + credentialType = '{camelCase}OAuth2Api'; |
| 225 | + const siteId = await getSiteId.call(this, credentialType, rawDomain); |
| 226 | + domain = `{GATEWAY_BASE_URL}/${siteId}`; |
| 227 | +} else { |
| 228 | +``` |
| 229 | +
|
| 230 | +The existing `uri: \`${domain}/rest${endpoint}\`` construction then produces the correct |
| 231 | +gateway URL automatically. |
| 232 | + |
| 233 | +Add `NodeOperationError` to the `n8n-workflow` import if not already present. |
| 234 | + |
| 235 | +--- |
| 236 | + |
| 237 | +## Step 7 — Update the node file(s) |
| 238 | + |
| 239 | +### Main node (`*.node.ts`) |
| 240 | + |
| 241 | +**Credentials array** — add an entry for the new credential type: |
| 242 | + |
| 243 | +```typescript |
| 244 | +{ |
| 245 | + name: '{camelCase}OAuth2Api', |
| 246 | + required: true, |
| 247 | + displayOptions: { show: { {versionParam}: ['{camelCase}OAuth2'] } }, |
| 248 | +}, |
| 249 | +``` |
| 250 | + |
| 251 | +**Version/auth options** — add to the `{versionParam}` (or equivalent) options list: |
| 252 | + |
| 253 | +```typescript |
| 254 | +{ name: '{Display Name} (OAuth2)', value: '{camelCase}OAuth2' }, |
| 255 | +``` |
| 256 | + |
| 257 | +Keep `default` unchanged — existing workflows must not be affected. |
| 258 | + |
| 259 | +### Trigger node (`*Trigger.node.ts`, if present) |
| 260 | + |
| 261 | +Same two changes. Preserve any `displayName` label pattern already used by other credential |
| 262 | +entries in that trigger node's credentials array. |
| 263 | + |
| 264 | +--- |
| 265 | + |
| 266 | +## Step 8 — Write credential tests |
| 267 | + |
| 268 | +File: `packages/nodes-base/credentials/test/{NODE_NAME}OAuth2Api.credentials.test.ts` |
| 269 | + |
| 270 | +Use `ClientOAuth2` from `@n8n/client-oauth2` and `nock` for HTTP mocking. Follow the |
| 271 | +structure in `MicrosoftTeamsOAuth2Api.credentials.test.ts`. |
| 272 | + |
| 273 | +Required test cases: |
| 274 | +1. **Metadata** — name, extends array, `enabledScopes` default, auth URL, token URL, |
| 275 | + `authQueryParameters` default (if applicable). |
| 276 | +2. **Default scopes in authorization URI** — call `oauthClient.code.getUri()`, assert each |
| 277 | + default scope is present. |
| 278 | +3. **Token retrieval with default scopes** — mock the token endpoint with `nock`, call |
| 279 | + `oauthClient.code.getToken(...)`, assert `token.data.scope` contains each scope. |
| 280 | +4. **Custom scopes in authorization URI** _(skip when CUSTOM_SCOPES = no)_. |
| 281 | +5. **Token retrieval with custom scopes** _(skip when CUSTOM_SCOPES = no)_. |
| 282 | +6. **Minimal / different scope set** _(skip when CUSTOM_SCOPES = no)_ — assert scopes not |
| 283 | + in the set are absent from both the URI and token response. |
| 284 | + |
| 285 | +Lifecycle hooks required: |
| 286 | +```typescript |
| 287 | +beforeAll(() => { nock.disableNetConnect(); }); |
| 288 | +afterAll(() => { nock.restore(); }); |
| 289 | +afterEach(() => { nock.cleanAll(); }); |
| 290 | +``` |
| 291 | + |
| 292 | +--- |
| 293 | + |
| 294 | +## Step 9 — Update `GenericFunctions.test.ts` |
| 295 | + |
| 296 | +In the credential-routing `describe` block: |
| 297 | + |
| 298 | +1. If a site-ID cache (`_cloudIdCache`) was added, import it and call |
| 299 | + `_cloudIdCache.clear()` (or equivalent) in `afterEach`. |
| 300 | +2. Add/update the OAuth2 routing test case: |
| 301 | + - **Simple routing**: assert `getCredentials` was called with the correct credential |
| 302 | + name and `requestWithAuthentication` was called with the correct name and URI. |
| 303 | + - **Gateway lookup**: mock `requestWithAuthentication` to return the accessible-resources |
| 304 | + payload on the first call and `{}` on the second. Assert the first call targets the |
| 305 | + resources endpoint and the second call uses the gateway base URL with the site ID. |
| 306 | + |
| 307 | +--- |
| 308 | + |
| 309 | +## Step 10 — Verify |
| 310 | + |
| 311 | +```bash |
| 312 | +# From packages/nodes-base/ |
| 313 | +pnpm test credentials/test/{NODE_NAME}OAuth2Api.credentials.test.ts |
| 314 | +pnpm test nodes/{NODE_NAME}/__test__/GenericFunctions.test.ts |
| 315 | +pnpm typecheck |
| 316 | +pnpm lint |
| 317 | +
|
| 318 | +# Only when constants.ts was changed: |
| 319 | +pushd ../cli && pnpm typecheck && popd |
| 320 | +``` |
| 321 | + |
| 322 | +Fix any type errors before finishing. Never skip `pnpm typecheck`. |
0 commit comments