@ixiam/n8n-nodes-civicrm 2.1.8 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,14 +40,119 @@ Download: https://civicrm.org/download
40
40
 
41
41
  ## 🔐 Credentials
42
42
 
43
- The node uses **Bearer Token Authentication**.
43
+ The node supports two authentication modes:
44
+
45
+ ### **1. Standard API Key Authentication**
46
+ Simple and reliable for most use cases.
47
+
48
+ | Field | Description |
49
+ |-------|-------------|
50
+ | **Base URL** | Root URL of CiviCRM. Example: `https://crm.example.org` |
51
+ | **API Token** | Your API Key from CiviCRM (sent as `X-Civi-Auth: Bearer <token>`) |
52
+
53
+ ### **2. JWT Authentication with Auto-Resolve (v3.0+)**
54
+ Time-bounded tokens for enhanced security. **Contact ID is automatically resolved** from your API Key.
44
55
 
45
56
  | Field | Description |
46
57
  |-------|-------------|
47
- | **Base URL** | The root URL of your CiviCRM instance (without trailing slash). Example: `https://crm.example.org` |
48
- | **API Token** | Sent as header `X-Civi-Auth: Bearer <token>` |
58
+ | **Base URL** | Root URL of CiviCRM |
59
+ | **API Token** | Your API Key (used to resolve Contact ID & generate JWT) |
60
+ | **Enable JWT Auth** | Toggle to enable time-bounded JWT tokens |
61
+ | **JWT Header Mode** | Where to send JWT (X-Civi-Auth recommended) |
62
+
63
+ #### How JWT Auto-Resolve Works
64
+
65
+ 1. When making an API call, the node automatically:
66
+ - Resolves your Contact ID from your API Key
67
+ - Generates a time-bounded JWT token (1 hour default)
68
+ - Caches both for efficiency
69
+
70
+ 2. The JWT is used for API requests, with **automatic fallback to API Key** if:
71
+ - JWT generation fails
72
+ - JWT returns empty results
73
+ - JWT expires
74
+
75
+ 3. **Zero manual configuration** - Contact ID is detected automatically
76
+
77
+ > **Note on warnings vs. results:** When JWT auth is enabled but the token can't be obtained (e.g. AuthX disabled or misconfigured on the CiviCRM side), the node still returns data — it silently retries the same request with the API Key. You'll see a **warning in the node's output pane** explaining why JWT wasn't used, but the workflow does not fail and the query results are still shown. This is intentional: JWT failures never break a workflow, they only fall back to API Key auth and surface a non-blocking warning.
78
+
79
+ #### JWT Setup Requirements
80
+
81
+ **CiviCRM Extensions & Settings:**
82
+ - ✓ AuthX extension enabled (CiviCRM 5.48+)
83
+ - ✓ API Key assigned to a Contact record
84
+
85
+ **CiviCRM Authentication Configuration**
86
+
87
+ The following settings must be enabled in CiviCRM's Authentication configuration:
88
+
89
+ ```
90
+ Administer → System Settings → Authentication
91
+ ├─ AuthX Header Authentication: ✓ ENABLED
92
+ ├─ AuthX XHeader Support: ✓ ENABLED
93
+ ├─ Credential Types: Include 'jwt'
94
+ └─ XHeader Name: Set to 'X-Civi-Auth' (default)
95
+ ```
49
96
 
50
- After entering credentials, click **Save** to validate the connection.
97
+ **Settings in civicrm.settings.php:**
98
+ ```php
99
+ // Enable AuthX for JWT tokens
100
+ define('CIVICRM_AUTHX_XHEADER_CRED', json_encode(['jwt']));
101
+
102
+ // Optional: Configure JWT signing keys (auto-generated)
103
+ // define('CIVICRM_AUTHX_SIGN_KEY', 'your-signing-key');
104
+ ```
105
+
106
+ **How to Enable:**
107
+ 1. Go to **Administer → System Settings → Authentication**
108
+ 2. Find **AuthX Settings** section
109
+ 3. Check: `✓ Enable X-Header Authentication`
110
+ 4. Check: `✓ JWT in X-Civi-Auth header`
111
+ 5. Verify **Header name**: `X-Civi-Auth`
112
+ 6. **Save** configuration
113
+
114
+ #### JWT Token Rotation & Security
115
+
116
+ **Token Lifetime & Auto-Refresh:**
117
+ - JWT tokens auto-expire after 1 hour (configurable in n8n credential)
118
+ - n8n automatically refreshes expired tokens (transparent to workflows)
119
+ - No manual intervention needed
120
+
121
+ **Security Best Practices:**
122
+
123
+ | Action | Frequency | Reason |
124
+ |--------|-----------|--------|
125
+ | **Rotate API Keys** | Every 90 days | Limits exposure window if compromised |
126
+ | **Review AuthX Logs** | Monthly | Detect unauthorized access attempts |
127
+ | **Monitor JWT Usage** | Real-time | Alert on unusual patterns |
128
+ | **Update CiviCRM** | As released | Includes security patches |
129
+ | **Audit n8n Workflows** | Quarterly | Verify only needed credentials used |
130
+
131
+ **When to Regenerate Credentials:**
132
+ - ✅ Regular security rotation (quarterly)
133
+ - ✅ Suspected credential compromise
134
+ - ✅ Staff member leaves organization
135
+ - ✅ n8n instance compromised
136
+ - ✅ Failed authentication attempts detected
137
+
138
+ **How to Revoke Access Immediately:**
139
+ ```
140
+ Option 1: Disable JWT (n8n credential)
141
+ ├─ Uncheck "Enable JWT Auth" toggle
142
+ └─ Node falls back to API Key (effective immediately)
143
+
144
+ Option 2: Regenerate API Key (CiviCRM)
145
+ ├─ Go to Contact record
146
+ ├─ Regenerate API Key field
147
+ └─ Old JWT & API Key become invalid instantly
148
+ ```
149
+
150
+ **→ See [JWT_QUICKSTART.md](JWT_QUICKSTART.md) for 5-minute setup**
151
+ **→ See [JWT_AUTORESOLVE_SETUP.md](JWT_AUTORESOLVE_SETUP.md) for detailed configuration**
152
+
153
+ ---
154
+
155
+ After entering credentials, click **Test credentials** to validate the connection.
51
156
 
52
157
  ---
53
158
 
@@ -128,13 +233,46 @@ Example:
128
233
  }
129
234
  ```
130
235
 
236
+ ### **7. JWT Authentication with Auto-Resolve (v3.0+)**
237
+ Enhanced security with time-bounded tokens:
238
+
239
+ **Features:**
240
+ - ✨ **Automatic Contact ID Resolution** - No manual input needed
241
+ - ✨ **Time-Bounded Tokens** - 1 hour default (configurable)
242
+ - ✨ **Smart Fallback** - Automatically falls back to API Key if JWT insufficient
243
+ - ✨ **Efficient Caching** - Contact ID & JWT cached for performance
244
+ - ✨ **Zero Configuration** - Just enable "JWT Auth" toggle
245
+
246
+ **Example Workflow:**
247
+ ```
248
+ Enable JWT in credential
249
+
250
+ Make API call
251
+
252
+ [Automatic]
253
+ ├─ Resolve: Which Contact owns this API Key?
254
+ ├─ Generate: Time-bounded JWT token
255
+ ├─ Try: API call with JWT
256
+ └─ Fallback: If empty, retry with API Key
257
+
258
+ Get results
259
+ ```
260
+
261
+ **Security Benefits vs API Key Only:**
262
+ - ✓ Tokens expire automatically (1 hour)
263
+ - ✓ Leaked token has limited lifetime
264
+ - ✓ Can't be used outside CiviCRM
265
+ - ✓ Full audit trail available
266
+
267
+ → **Learn more:** [JWT_AUTORESOLVE_SETUP.md](JWT_AUTORESOLVE_SETUP.md)
268
+
131
269
  ---
132
270
 
133
271
  ## Compatibility
134
272
 
135
273
  - **n8n version:** 1.0.0 or higher
136
274
  - **Node.js:** 18 or higher
137
- - **CiviCRM:** API v4 compatible (including Civi-Go)
275
+ - **CiviCRM:** API v4 compatible
138
276
 
139
277
  ---
140
278
 
@@ -26,15 +26,62 @@ class CiviCrmApi {
26
26
  default: '',
27
27
  required: true,
28
28
  },
29
- ];
30
- this.authenticate = {
31
- type: 'generic',
32
- properties: {
33
- headers: {
34
- 'X-Civi-Auth': '={{ "Bearer " + $credentials.apiToken }}',
29
+ {
30
+ displayName: 'Enable JWT Authentication',
31
+ name: 'enableJwtAuth',
32
+ type: 'boolean',
33
+ default: false,
34
+ description: 'Enable server-issued JWT authentication for improved security (time-bound tokens, auto-resolved contact ID). When disabled, uses API key authentication.',
35
+ },
36
+ {
37
+ displayName: 'JWT Header Mode',
38
+ name: 'jwtHeaderMode',
39
+ type: 'options',
40
+ default: 'xheader',
41
+ displayOptions: {
42
+ show: {
43
+ enableJwtAuth: [true],
44
+ },
35
45
  },
46
+ options: [
47
+ {
48
+ name: 'X-Civi-Auth Header (Recommended)',
49
+ value: 'xheader',
50
+ },
51
+ {
52
+ name: 'Authorization Header',
53
+ value: 'authorization',
54
+ },
55
+ {
56
+ name: 'Both Headers',
57
+ value: 'both',
58
+ },
59
+ ],
60
+ description: 'Where to send the JWT token.',
36
61
  },
37
- };
62
+ {
63
+ displayName: 'JWT Expiry (Seconds)',
64
+ name: 'jwtExpiry',
65
+ type: 'number',
66
+ default: 3600,
67
+ typeOptions: {
68
+ minValue: 60,
69
+ maxValue: 86400,
70
+ },
71
+ displayOptions: {
72
+ show: {
73
+ enableJwtAuth: [true],
74
+ },
75
+ },
76
+ description: 'Lifetime of the server-issued JWT, in seconds, before it must be renewed. Default is 3600 (1 hour).',
77
+ },
78
+ ];
79
+ // n8n's CredentialsTester always prefers a `test` defined directly here over the
80
+ // CiviCrm node's `testedBy: 'testCiviCrmApiConnection'` (see CiviCrm.node.ts), so
81
+ // that JWT-aware test never actually runs while this declarative test exists.
82
+ // It's kept as a plain API-key connectivity check so "Test credentials" always
83
+ // works reliably; the node's execution path (addExecutionHints) is what surfaces
84
+ // JWT-specific failures instead.
38
85
  this.test = {
39
86
  request: {
40
87
  baseURL: '={{$credentials.baseUrl}}',
@@ -42,6 +89,7 @@ class CiviCrmApi {
42
89
  method: 'POST',
43
90
  headers: {
44
91
  'Content-Type': 'application/x-www-form-urlencoded',
92
+ 'X-Civi-Auth': '={{ "Bearer " + $credentials.apiToken }}',
45
93
  },
46
94
  body: {
47
95
  params: JSON.stringify({ select: ['id'], limit: 1 }),
@@ -574,13 +574,13 @@ class CiviCrm {
574
574
  this.methods = {
575
575
  loadOptions: {
576
576
  async loadOptionValues() {
577
- const { baseUrl } = (await this.getCredentials('civiCrmApi'));
578
- const res = await this.helpers.httpRequestWithAuthentication.call(this, 'civiCrmApi', {
577
+ const credentials = (await this.getCredentials('civiCrmApi'));
578
+ const baseUrl = credentials.baseUrl.replace(/\/$/, '');
579
+ const headers = (0, GenericFunctions_1.buildCiviAuthHeaders)(credentials, baseUrl);
580
+ const res = await this.helpers.httpRequest.call(this, {
579
581
  method: 'POST',
580
- url: `${baseUrl.replace(/\/$/, '')}/civicrm/ajax/api4/OptionValue/get`,
581
- headers: {
582
- 'Content-Type': 'application/x-www-form-urlencoded',
583
- },
582
+ url: `${baseUrl}/civicrm/ajax/api4/OptionValue/get`,
583
+ headers,
584
584
  body: { params: JSON.stringify({ limit: 50, select: ['id', 'label'] }) },
585
585
  json: true,
586
586
  });
@@ -1,36 +1,176 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildCiviAuthHeaders = buildCiviAuthHeaders;
3
4
  exports.civicrmApiRequest = civicrmApiRequest;
4
5
  exports.api4 = api4;
5
6
  const n8n_workflow_1 = require("n8n-workflow");
7
+ const JwtAuth_1 = require("./JwtAuth");
8
+ function getJwtHeaderMode(credentials) {
9
+ var _a;
10
+ const rawMode = String((_a = credentials.jwtHeaderMode) !== null && _a !== void 0 ? _a : 'xheader');
11
+ if (rawMode === 'authorization' || rawMode === 'xheader' || rawMode === 'both') {
12
+ return rawMode;
13
+ }
14
+ return 'xheader';
15
+ }
16
+ function applyJwtHeaders(headers, jwtToken, headerMode) {
17
+ const bearer = `Bearer ${jwtToken}`;
18
+ if (headerMode === 'both' || headerMode === 'authorization') {
19
+ headers.Authorization = bearer;
20
+ }
21
+ if (headerMode === 'both' || headerMode === 'xheader') {
22
+ headers['X-Civi-Auth'] = bearer;
23
+ }
24
+ }
25
+ function getHttpErrorDetails(error) {
26
+ var _a, _b, _c;
27
+ const errorObj = error;
28
+ const status = (_a = errorObj === null || errorObj === void 0 ? void 0 : errorObj.response) === null || _a === void 0 ? void 0 : _a.status;
29
+ const data = (_b = errorObj === null || errorObj === void 0 ? void 0 : errorObj.response) === null || _b === void 0 ? void 0 : _b.data;
30
+ const message = (_c = errorObj === null || errorObj === void 0 ? void 0 : errorObj.message) !== null && _c !== void 0 ? _c : 'Request rejected by CiviCRM';
31
+ if (typeof data === 'string' && data.trim()) {
32
+ return status ? `${message} | HTTP ${status} body: ${data}` : `${message} | body: ${data}`;
33
+ }
34
+ if (data && typeof data === 'object') {
35
+ const serialized = JSON.stringify(data);
36
+ return status ? `${message} | HTTP ${status} body: ${serialized}` : `${message} | body: ${serialized}`;
37
+ }
38
+ return status ? `${message} | HTTP ${status}` : message;
39
+ }
40
+ function buildCiviAuthHeaders(credentials, baseUrl, jwtToken) {
41
+ const headers = {
42
+ 'Content-Type': 'application/x-www-form-urlencoded',
43
+ };
44
+ // Use JWT if available and enabled; otherwise always fall back to API key
45
+ if (jwtToken && (0, JwtAuth_1.isJwtAuthEnabled)(credentials)) {
46
+ const headerMode = getJwtHeaderMode(credentials);
47
+ applyJwtHeaders(headers, jwtToken, headerMode);
48
+ }
49
+ else {
50
+ // Always provide API key as fallback
51
+ headers['X-Civi-Auth'] = `Bearer ${credentials.apiToken}`;
52
+ }
53
+ return headers;
54
+ }
6
55
  /**
7
- * Executes a CiviCRM API v4 call (Civi-Go).
8
- * Uses form-urlencoded encoding with the "params" field serialized as JSON.
56
+ * Executes a CiviCRM API v4 call with authentication (JWT or API Key).
57
+ * Falls back to API Key if JWT is unavailable, fails, or returns empty results.
9
58
  */
10
59
  async function civicrmApiRequest(method, path, body) {
11
- var _a;
12
- const credentials = await this.getCredentials('civiCrmApi');
60
+ var _a, _b, _c;
61
+ const credentials = (await this.getCredentials('civiCrmApi'));
13
62
  const baseUrl = credentials.baseUrl.replace(/\/$/, '');
14
- const options = {
63
+ const apiToken = credentials.apiToken;
64
+ let jwtToken;
65
+ let useJwt = false;
66
+ // Attempt to obtain JWT if enabled
67
+ if ((0, JwtAuth_1.isJwtAuthEnabled)(credentials)) {
68
+ const ttl = Number((_a = credentials.jwtExpiry) !== null && _a !== void 0 ? _a : 3600);
69
+ try {
70
+ // Auto-resolve contact ID is built-in to getServerIssuedJwt
71
+ jwtToken = await (0, JwtAuth_1.getServerIssuedJwt)(this, baseUrl, apiToken, 0, ttl);
72
+ if (jwtToken) {
73
+ useJwt = true;
74
+ }
75
+ else {
76
+ console.warn('[CiviCRM] JWT generation returned no token, falling back to API key');
77
+ this.addExecutionHints({
78
+ message: 'JWT authentication could not be obtained (no token returned by CiviCRM). Falling back to API Key authentication.',
79
+ type: 'warning',
80
+ location: 'outputPane',
81
+ });
82
+ }
83
+ }
84
+ catch (error) {
85
+ const errorMsg = getHttpErrorDetails(error);
86
+ console.warn(`[CiviCRM] JWT generation failed: ${errorMsg}. Falling back to API key.`);
87
+ this.addExecutionHints({
88
+ message: `JWT authentication failed (${errorMsg}). Falling back to API Key authentication.`,
89
+ type: 'warning',
90
+ location: 'outputPane',
91
+ });
92
+ }
93
+ }
94
+ // Try with JWT first (if available)
95
+ if (useJwt && jwtToken) {
96
+ const headers = buildCiviAuthHeaders(credentials, baseUrl, jwtToken);
97
+ const options = {
98
+ method,
99
+ url: `${baseUrl}${path}`,
100
+ headers,
101
+ body: {
102
+ params: JSON.stringify((_b = body.params) !== null && _b !== void 0 ? _b : body),
103
+ },
104
+ json: true,
105
+ };
106
+ try {
107
+ const response = await this.helpers.httpRequest.call(this, options);
108
+ // Check if response has data. If JWT returned empty but we expected data, fallback to API key
109
+ const hasData = hasResponseData(response);
110
+ if (hasData) {
111
+ return response;
112
+ }
113
+ else {
114
+ console.warn('[CiviCRM] JWT returned empty response. Retrying with API key (JWT may have limited permissions).');
115
+ this.addExecutionHints({
116
+ message: 'The JWT-authenticated request returned no data (JWT may have limited permissions). Retrying with API Key authentication.',
117
+ type: 'warning',
118
+ location: 'outputPane',
119
+ });
120
+ // Fall through to API key attempt below
121
+ }
122
+ }
123
+ catch (error) {
124
+ const errorMsg = getHttpErrorDetails(error);
125
+ console.warn('[CiviCRM] JWT request failed. Retrying with API key.');
126
+ this.addExecutionHints({
127
+ message: `The JWT-authenticated request failed (${errorMsg}). Retrying with API Key authentication.`,
128
+ type: 'warning',
129
+ location: 'outputPane',
130
+ });
131
+ // Fall through to API key attempt below
132
+ }
133
+ }
134
+ // Fallback to API Key
135
+ const apiKeyHeaders = buildCiviAuthHeaders(credentials, baseUrl, undefined);
136
+ const apiKeyOptions = {
15
137
  method,
16
138
  url: `${baseUrl}${path}`,
17
- headers: {
18
- 'Content-Type': 'application/x-www-form-urlencoded',
19
- },
20
- // flat body as expected by Civi-Go
139
+ headers: apiKeyHeaders,
21
140
  body: {
22
- params: JSON.stringify((_a = body.params) !== null && _a !== void 0 ? _a : body),
141
+ params: JSON.stringify((_c = body.params) !== null && _c !== void 0 ? _c : body),
23
142
  },
24
143
  json: true,
25
144
  };
26
145
  try {
27
- const response = await this.helpers.httpRequestWithAuthentication.call(this, 'civiCrmApi', options);
146
+ const response = await this.helpers.httpRequest.call(this, apiKeyOptions);
147
+ if (useJwt && jwtToken) {
148
+ console.log('[CiviCRM] API key request successful (JWT was insufficient, using API key as fallback)');
149
+ }
28
150
  return response;
29
151
  }
30
152
  catch (error) {
31
153
  throw new n8n_workflow_1.NodeApiError(this.getNode(), error);
32
154
  }
33
155
  }
156
+ /**
157
+ * Check if API response contains actual data.
158
+ * Returns false if response is empty/no results, true if has data.
159
+ */
160
+ function hasResponseData(response) {
161
+ if (!response)
162
+ return false;
163
+ // Check for APIv4 response format: { values: [...], count: N }
164
+ if (response.values !== undefined) {
165
+ return Array.isArray(response.values) && response.values.length > 0;
166
+ }
167
+ // Check for other formats
168
+ if (Array.isArray(response)) {
169
+ return response.length > 0;
170
+ }
171
+ // If response exists and isn't an empty array, consider it has data
172
+ return Object.keys(response).length > 0;
173
+ }
34
174
  /**
35
175
  * Returns the standard body for API4 calls (flat params).
36
176
  */
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isJwtAuthEnabled = isJwtAuthEnabled;
4
+ exports.resolveContactId = resolveContactId;
5
+ exports.getServerIssuedJwt = getServerIssuedJwt;
6
+ const serverIssuedCache = {};
7
+ const contactIdCache = {}; // Cache resolved contact IDs
8
+ function isJwtAuthEnabled(credentials) {
9
+ return (credentials === null || credentials === void 0 ? void 0 : credentials.enableJwtAuth) === true;
10
+ }
11
+ /**
12
+ * Extracts a readable message from an HTTP error, including the response body
13
+ * (e.g. CiviCRM's "Login not permitted. Must satisfy guard (perm, site_key)."),
14
+ * which the bare `error.message` (e.g. "Request failed with status code 401")
15
+ * does not include.
16
+ */
17
+ function getErrorDetails(error) {
18
+ var _a, _b, _c;
19
+ const errorObj = error;
20
+ const status = (_a = errorObj === null || errorObj === void 0 ? void 0 : errorObj.response) === null || _a === void 0 ? void 0 : _a.status;
21
+ const data = (_b = errorObj === null || errorObj === void 0 ? void 0 : errorObj.response) === null || _b === void 0 ? void 0 : _b.data;
22
+ const message = (_c = errorObj === null || errorObj === void 0 ? void 0 : errorObj.message) !== null && _c !== void 0 ? _c : String(error);
23
+ const body = typeof data === 'string' && data.trim()
24
+ ? data
25
+ : data && typeof data === 'object'
26
+ ? JSON.stringify(data)
27
+ : undefined;
28
+ if (body) {
29
+ return status ? `${message} | HTTP ${status} body: ${body}` : `${message} | body: ${body}`;
30
+ }
31
+ return status ? `${message} | HTTP ${status}` : message;
32
+ }
33
+ /**
34
+ * Resolve the current authenticated user's contact ID from Contact/get.
35
+ * Finds the contact that owns the given api_key.
36
+ * Cached to avoid repeated lookups.
37
+ * Returns undefined if resolution fails (allowing caller to handle gracefully).
38
+ */
39
+ async function resolveContactId(context, baseUrl, apiToken) {
40
+ const cacheKey = `${baseUrl}:${apiToken}`;
41
+ if (contactIdCache[cacheKey]) {
42
+ return contactIdCache[cacheKey];
43
+ }
44
+ try {
45
+ const response = await context.helpers.httpRequest.call(context, {
46
+ method: 'POST',
47
+ url: `${baseUrl}/civicrm/ajax/api4/Contact/get`,
48
+ headers: {
49
+ 'Content-Type': 'application/x-www-form-urlencoded',
50
+ 'X-Civi-Auth': `Bearer ${apiToken}`,
51
+ },
52
+ body: {
53
+ params: JSON.stringify({
54
+ select: ['id'],
55
+ where: [['api_key', '=', apiToken]],
56
+ limit: 1,
57
+ }),
58
+ },
59
+ json: true,
60
+ });
61
+ const contacts = (response === null || response === void 0 ? void 0 : response.values) || [];
62
+ if (contacts.length === 0) {
63
+ console.warn('[CiviCRM] Contact/get found no contact with this api_key - JWT auto-resolve will be skipped');
64
+ return undefined;
65
+ }
66
+ const contactId = contacts[0].id;
67
+ contactIdCache[cacheKey] = contactId;
68
+ return contactId;
69
+ }
70
+ catch (error) {
71
+ const errorMsg = getErrorDetails(error);
72
+ console.warn(`[CiviCRM] Failed to auto-resolve contact ID: ${errorMsg}. Will attempt API key fallback.`);
73
+ return undefined;
74
+ }
75
+ }
76
+ /**
77
+ * Fetch JWT from CiviCRM AuthxCredential/create using API key.
78
+ * If contactId is 0 or undefined, automatically resolves it from Contact/get.
79
+ * CiviCRM signs and issues the JWT, guaranteeing AuthX compatibility.
80
+ * Returns undefined if JWT generation fails or permission denied, allowing fallback to API key.
81
+ */
82
+ async function getServerIssuedJwt(context, baseUrl, apiToken, contactId = 0, ttl = 3600) {
83
+ var _a, _b;
84
+ // Auto-resolve contact ID if not provided
85
+ let resolvedContactId = contactId;
86
+ if (resolvedContactId === 0 || resolvedContactId === undefined) {
87
+ const resolved = await resolveContactId(context, baseUrl, apiToken);
88
+ if (!resolved) {
89
+ // Auto-resolve failed, can't generate JWT
90
+ return undefined;
91
+ }
92
+ resolvedContactId = resolved;
93
+ }
94
+ const cacheKey = `${baseUrl}:${resolvedContactId}:${ttl}`;
95
+ const now = Date.now();
96
+ const cached = serverIssuedCache[cacheKey];
97
+ // Return cached token if still valid (with 30s buffer)
98
+ if (cached && cached.expiresAt > now + 30000) {
99
+ return cached.token;
100
+ }
101
+ try {
102
+ const response = await context.helpers.httpRequest.call(context, {
103
+ method: 'POST',
104
+ url: `${baseUrl}/civicrm/ajax/api4/AuthxCredential/create`,
105
+ headers: {
106
+ 'Content-Type': 'application/x-www-form-urlencoded',
107
+ 'X-Civi-Auth': `Bearer ${apiToken}`,
108
+ },
109
+ body: {
110
+ params: JSON.stringify({
111
+ contactId: resolvedContactId,
112
+ ttl: ttl,
113
+ }),
114
+ },
115
+ json: true,
116
+ });
117
+ const cred = ((_b = (_a = response === null || response === void 0 ? void 0 : response.values) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.cred) || '';
118
+ if (!cred.startsWith('Bearer ')) {
119
+ console.warn('[CiviCRM] Invalid JWT response: missing Bearer token');
120
+ return undefined;
121
+ }
122
+ const token = cred.substring(7); // Remove "Bearer " prefix
123
+ serverIssuedCache[cacheKey] = { token, expiresAt: now + ttl * 1000 };
124
+ return token;
125
+ }
126
+ catch (error) {
127
+ const errorMsg = getErrorDetails(error);
128
+ const isPermissionDenied = errorMsg.includes('Authorization failed') ||
129
+ errorMsg.includes('403') ||
130
+ errorMsg.includes('Permission denied') ||
131
+ errorMsg.includes('Login not permitted') ||
132
+ errorMsg.includes('Must satisfy guard');
133
+ if (isPermissionDenied) {
134
+ // User lacks permissions for JWT. Fall back to API key auth.
135
+ console.warn(`[CiviCRM JWT] User lacks permissions for AuthxCredential/create (${errorMsg}). Will use API key authentication instead.`);
136
+ return undefined;
137
+ }
138
+ console.warn(`[CiviCRM JWT] Failed to obtain JWT: ${errorMsg}. Falling back to API key.`);
139
+ return undefined;
140
+ }
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixiam/n8n-nodes-civicrm",
3
- "version": "2.1.8",
3
+ "version": "3.0.0",
4
4
  "description": "Full-featured CiviCRM API v4 integration for n8n",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -50,12 +50,16 @@
50
50
  },
51
51
  "devDependencies": {
52
52
  "@n8n/node-cli": "^0.23.1",
53
+ "@types/jsonwebtoken": "^9.0.10",
53
54
  "@types/node": "^20.11.30",
54
55
  "eslint": "9.32.0",
55
56
  "prettier": "3.6.2",
56
57
  "release-it": "^19.0.6",
57
58
  "typescript": "5.9.2"
58
59
  },
60
+ "dependencies": {
61
+ "jsonwebtoken": "^9.0.3"
62
+ },
59
63
  "peerDependencies": {
60
64
  "n8n-workflow": "*"
61
65
  },