auth0-deploy-cli 8.43.0 → 8.44.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/lib/context/defaults.d.ts +7 -0
  3. package/lib/context/defaults.js +74 -30
  4. package/lib/context/directory/handlers/attackProtection.js +1 -1
  5. package/lib/context/directory/handlers/branding.js +9 -1
  6. package/lib/context/directory/handlers/clients.js +9 -3
  7. package/lib/context/directory/handlers/connections.js +10 -4
  8. package/lib/context/directory/handlers/emailTemplates.js +9 -0
  9. package/lib/context/directory/handlers/prompts.js +11 -3
  10. package/lib/context/yaml/handlers/attackProtection.js +1 -1
  11. package/lib/context/yaml/handlers/branding.js +8 -1
  12. package/lib/context/yaml/handlers/clients.js +4 -1
  13. package/lib/context/yaml/handlers/connections.js +3 -1
  14. package/lib/context/yaml/handlers/flows.js +7 -0
  15. package/lib/context/yaml/handlers/forms.js +7 -0
  16. package/lib/context/yaml/handlers/prompts.js +7 -1
  17. package/lib/readonly.js +5 -0
  18. package/lib/tools/auth0/handlers/attackProtection.js +3 -1
  19. package/lib/tools/auth0/handlers/clients.d.ts +21 -0
  20. package/lib/tools/auth0/handlers/clients.js +21 -0
  21. package/lib/tools/auth0/handlers/connectionProfiles.d.ts +27 -0
  22. package/lib/tools/auth0/handlers/connectionProfiles.js +30 -0
  23. package/lib/tools/auth0/handlers/connections.js +10 -3
  24. package/lib/tools/auth0/handlers/databases.js +1 -1
  25. package/lib/tools/auth0/handlers/default.d.ts +23 -0
  26. package/lib/tools/auth0/handlers/default.js +21 -10
  27. package/lib/tools/auth0/handlers/organizations.d.ts +1 -1
  28. package/lib/tools/auth0/handlers/organizations.js +39 -16
  29. package/lib/tools/auth0/handlers/phoneProvider.js +10 -2
  30. package/lib/tools/calculateDryRunChanges.js +12 -8
  31. package/lib/utils.js +5 -2
  32. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [8.44.0] - 2026-08-26
11
+
12
+ ### Added
13
+
14
+ - Support `third_party_client_access` and `cross_app_access_resource_app` on clients and connections. [#1471]
15
+
16
+ ### Fixed
17
+
18
+ - Warn on path traversal in file references and fix double-path resolution in YAML connections handler. [#1477]
19
+ - Strip `form_submission_mode` and `form_submission_behavior` from `bruteForceProtection` on export and import. [#1476]
20
+ - Include empty credentials for custom phone provider on import and export to prevent 400 errors. [#1474]
21
+ - Preserve keyword placeholder strings for all email provider credentials. [#1470]
22
+ - Preserve `idpinitiated.enabled:false` on SAML connection export instead of emitting `undefined_clientId`. [#1465]
23
+ - Apply 429 backoff to organization connection writes to prevent rate-limit failures. [#1464]
24
+ - Resolve dry-run reporting bugs across multiple asset types. [#1462]
25
+ - Skip organization PATCH when no top-level properties have changed. [#1461]
26
+ - Sort `attackProtection` arrays on export to prevent spurious diffs. [#1460]
27
+
10
28
  ## [8.43.0] - 2026-08-12
11
29
 
12
30
  ### Added
@@ -1927,7 +1945,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1927
1945
  [#1454]: https://github.com/auth0/auth0-deploy-cli/issues/1454
1928
1946
  [#1456]: https://github.com/auth0/auth0-deploy-cli/issues/1456
1929
1947
  [#1457]: https://github.com/auth0/auth0-deploy-cli/issues/1457
1930
- [Unreleased]: https://github.com/auth0/auth0-deploy-cli/compare/v8.43.0...HEAD
1948
+ [#1460]: https://github.com/auth0/auth0-deploy-cli/issues/1460
1949
+ [#1461]: https://github.com/auth0/auth0-deploy-cli/issues/1461
1950
+ [#1462]: https://github.com/auth0/auth0-deploy-cli/issues/1462
1951
+ [#1464]: https://github.com/auth0/auth0-deploy-cli/issues/1464
1952
+ [#1465]: https://github.com/auth0/auth0-deploy-cli/issues/1465
1953
+ [#1470]: https://github.com/auth0/auth0-deploy-cli/issues/1470
1954
+ [#1471]: https://github.com/auth0/auth0-deploy-cli/issues/1471
1955
+ [#1474]: https://github.com/auth0/auth0-deploy-cli/issues/1474
1956
+ [#1476]: https://github.com/auth0/auth0-deploy-cli/issues/1476
1957
+ [#1477]: https://github.com/auth0/auth0-deploy-cli/issues/1477
1958
+ [Unreleased]: https://github.com/auth0/auth0-deploy-cli/compare/v8.44.0...HEAD
1959
+ [8.44.0]: https://github.com/auth0/auth0-deploy-cli/compare/v8.43.0...v8.44.0
1931
1960
  [8.43.0]: https://github.com/auth0/auth0-deploy-cli/compare/v8.42.0...v8.43.0
1932
1961
  [8.42.0]: https://github.com/auth0/auth0-deploy-cli/compare/v8.41.0...v8.42.0
1933
1962
  [8.41.0]: https://github.com/auth0/auth0-deploy-cli/compare/v8.40.0...v8.41.0
@@ -6,4 +6,11 @@ export declare function phoneTemplatesDefaults(phoneTemplate: any): any;
6
6
  export declare function connectionDefaults(connection: any, config?: Pick<Config, 'AUTH0_EXPORT_SECRETS'>): any;
7
7
  export declare function logStreamDefaults(logStreams: any, config?: Pick<Config, 'AUTH0_EXPORT_SECRETS'>): any;
8
8
  export declare function eventStreamDefaults(eventStreams: any[], config?: Pick<Config, 'AUTH0_EXPORT_SECRETS'>): any[];
9
+ /**
10
+ * Sorts primitive arrays (e.g. `shields`, `admin_notification_frequency`) within the
11
+ * attackProtection subtree in place, so repeated exports produce deterministic output.
12
+ * These are unordered, string-typed enum sets — ordering is cosmetic. Object arrays are
13
+ * left as-is.
14
+ */
15
+ export declare function sortAttackProtectionArrays(attackProtection: AttackProtection): AttackProtection;
9
16
  export declare function attackProtectionDefaults(attackProtection: AttackProtection, config?: Pick<Config, 'AUTH0_EXPORT_SECRETS'>): AttackProtection;
@@ -6,6 +6,7 @@ exports.phoneTemplatesDefaults = phoneTemplatesDefaults;
6
6
  exports.connectionDefaults = connectionDefaults;
7
7
  exports.logStreamDefaults = logStreamDefaults;
8
8
  exports.eventStreamDefaults = eventStreamDefaults;
9
+ exports.sortAttackProtectionArrays = sortAttackProtectionArrays;
9
10
  exports.attackProtectionDefaults = attackProtectionDefaults;
10
11
  const utils_1 = require("../tools/utils");
11
12
  // env vars arrive as strings, so check both forms
@@ -22,45 +23,57 @@ function emailProviderDefaults(emailProvider, config) {
22
23
  // Add placeholder for credentials as they cannot be exported
23
24
  const { name } = updated;
24
25
  if (apiKeyProviders.includes(name)) {
25
- updated.credentials = {
26
- api_key: `##${name.toUpperCase()}_API_KEY##`,
27
- ...(updated.credentials || {}),
28
- };
26
+ if (typeof updated.credentials !== 'string') {
27
+ updated.credentials = {
28
+ api_key: `##${name.toUpperCase()}_API_KEY##`,
29
+ ...(updated.credentials || {}),
30
+ };
31
+ }
29
32
  }
30
33
  if (name === 'smtp') {
31
- // This is to mask smtp_user to '##SMTP_USER##'
32
- if (updated.credentials && 'smtp_user' in updated.credentials) {
33
- delete updated.credentials.smtp_user;
34
+ // If credentials is a keyword placeholder string (e.g. @@SMTP_CREDENTIALS@@), preserve it as-is.
35
+ // The `in` operator requires an object and would throw a TypeError on a string.
36
+ if (typeof updated.credentials !== 'string') {
37
+ // This is to mask smtp_user to '##SMTP_USER##'
38
+ if (updated.credentials && 'smtp_user' in updated.credentials) {
39
+ delete updated.credentials.smtp_user;
40
+ }
41
+ updated.credentials = {
42
+ smtp_host: '##SMTP_HOSTNAME##',
43
+ smtp_port: '##SMTP_PORT##',
44
+ smtp_user: '##SMTP_USER##',
45
+ smtp_pass: '##SMTP_PASS##',
46
+ ...(updated.credentials || {}),
47
+ };
34
48
  }
35
- updated.credentials = {
36
- smtp_host: '##SMTP_HOSTNAME##',
37
- smtp_port: '##SMTP_PORT##',
38
- smtp_user: '##SMTP_USER##',
39
- smtp_pass: '##SMTP_PASS##',
40
- ...(updated.credentials || {}),
41
- };
42
49
  }
43
50
  if (name === 'ses') {
44
- updated.credentials = {
45
- accessKeyId: '##SES_ACCESS_KEY_ID##',
46
- secretAccessKey: '##SES_ACCESS_SECRET_KEY##',
47
- region: '##SES_AWS_REGION##',
48
- ...(updated.credentials || {}),
49
- };
51
+ if (typeof updated.credentials !== 'string') {
52
+ updated.credentials = {
53
+ accessKeyId: '##SES_ACCESS_KEY_ID##',
54
+ secretAccessKey: '##SES_ACCESS_SECRET_KEY##',
55
+ region: '##SES_AWS_REGION##',
56
+ ...(updated.credentials || {}),
57
+ };
58
+ }
50
59
  }
51
60
  if (name === 'azure_cs') {
52
- updated.credentials = {
53
- connectionString: '##AZURE_CS_CONNECTION_KEY##',
54
- ...(updated.credentials || {}),
55
- };
61
+ if (typeof updated.credentials !== 'string') {
62
+ updated.credentials = {
63
+ connectionString: '##AZURE_CS_CONNECTION_KEY##',
64
+ ...(updated.credentials || {}),
65
+ };
66
+ }
56
67
  }
57
68
  if (name === 'ms365') {
58
- updated.credentials = {
59
- tenantId: '##MS365_TENANT_ID##',
60
- clientId: '##MS365_CLIENT_ID##',
61
- clientSecret: '##MS365_CLIENT_SECRET##',
62
- ...(updated.credentials || {}),
63
- };
69
+ if (typeof updated.credentials !== 'string') {
70
+ updated.credentials = {
71
+ tenantId: '##MS365_TENANT_ID##',
72
+ clientId: '##MS365_CLIENT_ID##',
73
+ clientSecret: '##MS365_CLIENT_SECRET##',
74
+ ...(updated.credentials || {}),
75
+ };
76
+ }
64
77
  }
65
78
  return updated;
66
79
  }
@@ -87,6 +100,12 @@ function phoneProviderDefaults(phoneProvider) {
87
100
  auth_token: `##${name.toUpperCase()}_AUTH_TOKEN##`,
88
101
  };
89
102
  }
103
+ else if (name === 'custom') {
104
+ // The `custom` provider has no exportable secrets, but the API requires a
105
+ // `credentials` object on import even when empty. Emit an empty object so
106
+ // exported configs round-trip cleanly.
107
+ updated.credentials = {};
108
+ }
90
109
  return updated;
91
110
  }
92
111
  function phoneTemplatesDefaults(phoneTemplate) {
@@ -193,6 +212,31 @@ function eventStreamDefaults(eventStreams, config) {
193
212
  };
194
213
  });
195
214
  }
215
+ /**
216
+ * Sorts primitive arrays (e.g. `shields`, `admin_notification_frequency`) within the
217
+ * attackProtection subtree in place, so repeated exports produce deterministic output.
218
+ * These are unordered, string-typed enum sets — ordering is cosmetic. Object arrays are
219
+ * left as-is.
220
+ */
221
+ function sortAttackProtectionArrays(attackProtection) {
222
+ const sortArraysDeep = (value) => {
223
+ if (Array.isArray(value)) {
224
+ const isPrimitiveArray = value.every((item) => item === null || typeof item !== 'object');
225
+ if (isPrimitiveArray) {
226
+ value.sort();
227
+ }
228
+ else {
229
+ value.forEach(sortArraysDeep);
230
+ }
231
+ return;
232
+ }
233
+ if (value && typeof value === 'object') {
234
+ Object.values(value).forEach(sortArraysDeep);
235
+ }
236
+ };
237
+ sortArraysDeep(attackProtection);
238
+ return attackProtection;
239
+ }
196
240
  function attackProtectionDefaults(attackProtection, config) {
197
241
  if (isExportSecrets(config))
198
242
  return attackProtection;
@@ -65,7 +65,7 @@ async function dump(context) {
65
65
  return;
66
66
  const files = attackProtectionFiles(context.filePath);
67
67
  fs_extra_1.default.ensureDirSync(files.directory);
68
- const maskedAttackProtection = (0, defaults_1.attackProtectionDefaults)(attackProtection, context.config);
68
+ const maskedAttackProtection = (0, defaults_1.sortAttackProtectionArrays)((0, defaults_1.attackProtectionDefaults)(attackProtection, context.config));
69
69
  if (maskedAttackProtection.botDetection) {
70
70
  (0, utils_1.dumpJSON)(files.botDetection, maskedAttackProtection.botDetection);
71
71
  }
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const path_1 = __importDefault(require("path"));
7
7
  const fs_extra_1 = __importDefault(require("fs-extra"));
8
8
  const tools_1 = require("../../../tools");
9
+ const logger_1 = __importDefault(require("../../../logger"));
9
10
  const utils_1 = require("../../../utils");
10
11
  function parse(context) {
11
12
  const brandingDirectory = path_1.default.join(context.filePath, tools_1.constants.BRANDING_DIRECTORY);
@@ -31,7 +32,14 @@ function parse(context) {
31
32
  disableKeywordReplacement: context.disableKeywordReplacement,
32
33
  });
33
34
  const normalizedPathArray = (0, utils_1.nomalizedYAMLPath)(definition.body);
34
- definition.body = (0, tools_1.loadFileAndReplaceKeywords)(path_1.default.join(brandingTemplatesFolder, ...normalizedPathArray), {
35
+ const resolvedBodyFile = path_1.default.resolve(brandingTemplatesFolder, ...normalizedPathArray);
36
+ const configRoot = path_1.default.resolve(context.filePath);
37
+ if (!resolvedBodyFile.startsWith(configRoot + path_1.default.sep)) {
38
+ logger_1.default.warn(`Branding template body path "${definition.body}" resolves to "${resolvedBodyFile}" which is outside the config directory "${configRoot}". ` +
39
+ `This will be blocked as an error in the next major release. ` +
40
+ `Move the file inside your config directory.`);
41
+ }
42
+ definition.body = (0, tools_1.loadFileAndReplaceKeywords)(resolvedBodyFile, {
35
43
  mappings: context.mappings,
36
44
  disableKeywordReplacement: context.disableKeywordReplacement,
37
45
  });
@@ -20,9 +20,15 @@ function parse(context) {
20
20
  disableKeywordReplacement: context.disableKeywordReplacement,
21
21
  });
22
22
  if (client.custom_login_page) {
23
- const htmlFileName = path_1.default.join(clientsFolder, client.custom_login_page);
24
- if ((0, utils_1.isFile)(htmlFileName)) {
25
- client.custom_login_page = (0, tools_1.loadFileAndReplaceKeywords)(htmlFileName, {
23
+ const configRoot = path_1.default.resolve(context.filePath);
24
+ const resolvedLoginPage = path_1.default.resolve(clientsFolder, client.custom_login_page);
25
+ if ((0, utils_1.isFile)(resolvedLoginPage)) {
26
+ if (!resolvedLoginPage.startsWith(configRoot + path_1.default.sep)) {
27
+ logger_1.default.warn(`Path "${client.custom_login_page}" resolves to "${resolvedLoginPage}" which is outside the config directory "${configRoot}". ` +
28
+ `This will be blocked as an error in the next major release. ` +
29
+ `Move the file inside your config directory.`);
30
+ }
31
+ client.custom_login_page = (0, tools_1.loadFileAndReplaceKeywords)(resolvedLoginPage, {
26
32
  mappings: context.mappings,
27
33
  disableKeywordReplacement: context.disableKeywordReplacement,
28
34
  });
@@ -23,11 +23,17 @@ function parse(context) {
23
23
  });
24
24
  if (connection.strategy === 'email') {
25
25
  (0, utils_1.ensureProp)(connection, 'options.email.body');
26
- const htmlFileName = path_1.default.join(connectionsFolder, connection.options.email.body);
27
- if (!(0, utils_1.isFile)(htmlFileName)) {
28
- throw new Error(`Passwordless email template purportedly located at ${htmlFileName} does not exist for connection. Ensure the existence of this file to proceed with deployment.`);
26
+ const configRoot = path_1.default.resolve(context.filePath);
27
+ const resolvedHtmlFile = path_1.default.resolve(connectionsFolder, connection.options.email.body);
28
+ if (!(0, utils_1.isFile)(resolvedHtmlFile)) {
29
+ throw new Error(`Passwordless email template purportedly located at ${resolvedHtmlFile} does not exist for connection. Ensure the existence of this file to proceed with deployment.`);
29
30
  }
30
- connection.options.email.body = (0, tools_1.loadFileAndReplaceKeywords)(htmlFileName, {
31
+ if (!resolvedHtmlFile.startsWith(configRoot + path_1.default.sep)) {
32
+ logger_1.default.warn(`Path "${connection.options.email.body}" resolves to "${resolvedHtmlFile}" which is outside the config directory "${configRoot}". ` +
33
+ `This will be blocked as an error in the next major release. ` +
34
+ `Move the file inside your config directory.`);
35
+ }
36
+ connection.options.email.body = (0, tools_1.loadFileAndReplaceKeywords)(resolvedHtmlFile, {
31
37
  mappings: context.mappings,
32
38
  disableKeywordReplacement: context.disableKeywordReplacement,
33
39
  });
@@ -34,6 +34,15 @@ function parse(context) {
34
34
  logger_1.default.warn(`Skipping email template file ${meta.body} as missing the corresponding '.json' file`);
35
35
  return [];
36
36
  }
37
+ if (meta.body !== undefined) {
38
+ const configRoot = path_1.default.resolve(context.filePath);
39
+ const resolvedTemplatePath = path_1.default.resolve(templateFilePath);
40
+ if (!resolvedTemplatePath.startsWith(configRoot + path_1.default.sep)) {
41
+ logger_1.default.warn(`Email template body path "${meta.body}" resolves to "${resolvedTemplatePath}" which is outside the config directory "${configRoot}". ` +
42
+ `This will be blocked as an error in the next major release. ` +
43
+ `Move the file inside your config directory.`);
44
+ }
45
+ }
37
46
  return {
38
47
  ...meta,
39
48
  body: (0, tools_1.loadFileAndReplaceKeywords)(templateFilePath, {
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const path_1 = __importDefault(require("path"));
7
7
  const fs_extra_1 = require("fs-extra");
8
8
  const tools_1 = require("../../../tools");
9
+ const logger_1 = __importDefault(require("../../../logger"));
9
10
  const utils_1 = require("../../../utils");
10
11
  const getPromptsDirectory = (filePath) => path_1.default.join(filePath, tools_1.constants.PROMPTS_DIRECTORY);
11
12
  const getPromptsSettingsFile = (promptsDirectory) => path_1.default.join(promptsDirectory, 'prompts.json');
@@ -46,9 +47,16 @@ function parse(context) {
46
47
  const screensObject = screensArray[0];
47
48
  acc[promptName] = Object.entries(screensObject).reduce((screenAcc, [screenName, items]) => {
48
49
  screenAcc[screenName] = items.reduce((insertionAcc, { name, template }) => {
49
- const templateFilePath = path_1.default.join(promptsDirectory, template);
50
- insertionAcc[name] = (0, utils_1.isFile)(templateFilePath)
51
- ? (0, tools_1.loadFileAndReplaceKeywords)(templateFilePath, {
50
+ const configRoot = path_1.default.resolve(context.filePath);
51
+ const resolvedTemplatePath = path_1.default.resolve(promptsDirectory, template);
52
+ if ((0, utils_1.isFile)(resolvedTemplatePath) &&
53
+ !resolvedTemplatePath.startsWith(configRoot + path_1.default.sep)) {
54
+ logger_1.default.warn(`Prompt partial template "${template}" resolves to "${resolvedTemplatePath}" which is outside the config directory "${configRoot}". ` +
55
+ `This will be blocked as an error in the next major release. ` +
56
+ `Move the file inside your config directory.`);
57
+ }
58
+ insertionAcc[name] = (0, utils_1.isFile)(resolvedTemplatePath)
59
+ ? (0, tools_1.loadFileAndReplaceKeywords)(resolvedTemplatePath, {
52
60
  mappings: context.mappings,
53
61
  disableKeywordReplacement: context.disableKeywordReplacement,
54
62
  }).trim()
@@ -27,7 +27,7 @@ async function dump(context) {
27
27
  }
28
28
  const maskedAttackProtection = (0, defaults_1.attackProtectionDefaults)(attackProtectionConfig, context.config);
29
29
  return {
30
- attackProtection: maskedAttackProtection,
30
+ attackProtection: (0, defaults_1.sortAttackProtectionArrays)(maskedAttackProtection),
31
31
  };
32
32
  }
33
33
  const attackProtectionHandler = {
@@ -18,9 +18,16 @@ async function parse(context) {
18
18
  const parsedTemplates = templates.map((templateDefinition) => {
19
19
  const normalizedPathArray = (0, utils_1.nomalizedYAMLPath)(templateDefinition.body);
20
20
  const markupFile = path_1.default.join(context.basePath, ...normalizedPathArray);
21
+ const configRoot = path_1.default.resolve(context.basePath);
22
+ const resolvedMarkupFile = path_1.default.resolve(markupFile);
23
+ if (!resolvedMarkupFile.startsWith(configRoot + path_1.default.sep)) {
24
+ logger_1.default.warn(`Branding template body path "${templateDefinition.body}" resolves to "${resolvedMarkupFile}" which is outside the config directory "${configRoot}". ` +
25
+ `This will be blocked as an error in the next major release. ` +
26
+ `Move the file inside your config directory.`);
27
+ }
21
28
  return {
22
29
  template: templateDefinition.template,
23
- body: (0, tools_1.loadFileAndReplaceKeywords)(markupFile, {
30
+ body: (0, tools_1.loadFileAndReplaceKeywords)(resolvedMarkupFile, {
24
31
  mappings: context.mappings,
25
32
  disableKeywordReplacement: context.disableKeywordReplacement,
26
33
  }),
@@ -21,7 +21,10 @@ async function parse(context) {
21
21
  if (client.custom_login_page) {
22
22
  const htmlFileName = path_1.default.join(clientsFolder, client.custom_login_page);
23
23
  if ((0, utils_1.isFile)(htmlFileName)) {
24
- client.custom_login_page = context.loadFile(htmlFileName);
24
+ // Pass a path relative to basePath so context.loadFile() resolves it correctly.
25
+ // Passing htmlFileName (which already includes basePath) would cause double resolution
26
+ // when basePath is a relative path (e.g. when AUTH0_INPUT_FILE is a relative path).
27
+ client.custom_login_page = context.loadFile(path_1.default.join(tools_1.constants.CLIENTS_DIRECTORY, client.custom_login_page));
25
28
  }
26
29
  }
27
30
  return client;
@@ -26,7 +26,9 @@ async function parse(context) {
26
26
  logger_1.default.error(missingTemplateErrorMessage);
27
27
  throw new Error(missingTemplateErrorMessage);
28
28
  }
29
- connection.options.email.body = context.loadFile(htmlFileName);
29
+ // Pass a path relative to basePath so context.loadFile() resolves it correctly.
30
+ // Passing htmlFileName (which already includes basePath) would cause double resolution.
31
+ connection.options.email.body = context.loadFile(path_1.default.join(tools_1.constants.CONNECTIONS_DIRECTORY, connection.options.email.body));
30
32
  }
31
33
  return connection;
32
34
  }),
@@ -14,6 +14,13 @@ async function parse(context) {
14
14
  return { flows: null };
15
15
  const parsedFlows = flows.map((flow) => {
16
16
  const flowFile = path_1.default.join(context.basePath, flow.body);
17
+ const configRoot = path_1.default.resolve(context.basePath);
18
+ const resolvedFlowFile = path_1.default.resolve(flowFile);
19
+ if (!resolvedFlowFile.startsWith(configRoot + path_1.default.sep)) {
20
+ logger_1.default.warn(`Flow body file "${flow.body}" resolves to "${resolvedFlowFile}" which is outside the config directory "${configRoot}". ` +
21
+ `This will be blocked as an error in the next major release. ` +
22
+ `Move the file inside your config directory.`);
23
+ }
17
24
  const parsedFlowBody = (0, utils_1.loadJSON)(flowFile, {
18
25
  mappings: context.mappings,
19
26
  disableKeywordReplacement: context.disableKeywordReplacement,
@@ -14,6 +14,13 @@ async function parse(context) {
14
14
  return { forms: null };
15
15
  const parsedForms = forms.map((form) => {
16
16
  const formFile = path_1.default.join(context.basePath, form.body);
17
+ const configRoot = path_1.default.resolve(context.basePath);
18
+ const resolvedFormFile = path_1.default.resolve(formFile);
19
+ if (!resolvedFormFile.startsWith(configRoot + path_1.default.sep)) {
20
+ logger_1.default.warn(`Form body file "${form.body}" resolves to "${resolvedFormFile}" which is outside the config directory "${configRoot}". ` +
21
+ `This will be blocked as an error in the next major release. ` +
22
+ `Move the file inside your config directory.`);
23
+ }
17
24
  const parsedFormBody = (0, utils_1.loadJSON)(formFile, {
18
25
  mappings: context.mappings,
19
26
  disableKeywordReplacement: context.disableKeywordReplacement,
@@ -19,7 +19,13 @@ const loadScreenRenderers = (context, screenRenderArray) => {
19
19
  Object.entries(screens).forEach(([, fileName]) => {
20
20
  const filePath = fileName;
21
21
  try {
22
- const rendererFile = path_1.default.join(context.basePath, filePath);
22
+ const configRoot = path_1.default.resolve(context.basePath);
23
+ const rendererFile = path_1.default.resolve(context.basePath, filePath);
24
+ if (!rendererFile.startsWith(configRoot + path_1.default.sep)) {
25
+ logger_1.default.warn(`Screen renderer file "${filePath}" resolves to "${rendererFile}" which is outside the config directory "${configRoot}". ` +
26
+ `This will be blocked as an error in the next major release. ` +
27
+ `Move the file inside your config directory.`);
28
+ }
23
29
  const rendererData = (0, utils_1.loadJSON)(rendererFile, {
24
30
  mappings: context.mappings,
25
31
  disableKeywordReplacement: context.disableKeywordReplacement,
package/lib/readonly.js CHANGED
@@ -28,6 +28,11 @@ const readOnlyFields = {
28
28
  'config_route',
29
29
  'owners',
30
30
  ],
31
+ attackProtection: [
32
+ // Returned by GET but rejected by PATCH with "Additional properties not allowed"
33
+ 'bruteForceProtection.form_submission_mode',
34
+ 'bruteForceProtection.form_submission_behavior',
35
+ ],
31
36
  };
32
37
  function getExcludedFields(config) {
33
38
  const strippedFields = { ...readOnlyFields };
@@ -314,7 +314,9 @@ class AttackProtectionHandler extends default_1.default {
314
314
  updates.push(this.client.attackProtection.captcha.update(attackProtection.captcha));
315
315
  }
316
316
  if (attackProtection.bruteForceProtection) {
317
- updates.push(this.client.attackProtection.bruteForceProtection.update(attackProtection.bruteForceProtection));
317
+ // Strip fields returned by GET but rejected by PATCH
318
+ const { form_submission_mode, form_submission_behavior, ...bruteForcePayload } = attackProtection.bruteForceProtection;
319
+ updates.push(this.client.attackProtection.bruteForceProtection.update(bruteForcePayload));
318
320
  }
319
321
  if (attackProtection.suspiciousIpThrottling) {
320
322
  updates.push(this.client.attackProtection.suspiciousIpThrottling.update(attackProtection.suspiciousIpThrottling));
@@ -254,6 +254,27 @@ export declare const schema: {
254
254
  enum: ("allow" | "allow_if_empty")[];
255
255
  description: string;
256
256
  };
257
+ third_party_client_access: {
258
+ type: string;
259
+ description: string;
260
+ properties: {
261
+ default_value: {
262
+ type: string;
263
+ enum: "block"[];
264
+ description: string;
265
+ };
266
+ allowed_values: {
267
+ type: string;
268
+ items: {
269
+ type: string;
270
+ enum: ("allow" | "block")[];
271
+ };
272
+ uniqueItems: boolean;
273
+ description: string;
274
+ };
275
+ };
276
+ required: string[];
277
+ };
257
278
  invitation_landing_client_id: {
258
279
  type: string;
259
280
  description: string;
@@ -59,6 +59,27 @@ const myOrganizationConfigurationSchema = {
59
59
  enum: Object.values(auth0_1.Management.ClientMyOrganizationDeletionBehaviorEnum),
60
60
  description: 'The deletion behavior for My Organization connections created by this client',
61
61
  },
62
+ third_party_client_access: {
63
+ type: 'object',
64
+ description: 'Controls whether third-party clients can access the organization via the My Organization API.',
65
+ properties: {
66
+ default_value: {
67
+ type: 'string',
68
+ enum: Object.values(auth0_1.Management.ClientMyOrganizationConfigurationThirdPartyClientAccessDefaultValueEnum),
69
+ description: 'The default third-party client access value.',
70
+ },
71
+ allowed_values: {
72
+ type: 'array',
73
+ items: {
74
+ type: 'string',
75
+ enum: Object.values(auth0_1.Management.ClientMyOrganizationConfigurationThirdPartyClientAccessAllowedValuesEnum),
76
+ },
77
+ uniqueItems: true,
78
+ description: 'The allowed third-party client access values.',
79
+ },
80
+ },
81
+ required: ['default_value', 'allowed_values'],
82
+ },
62
83
  invitation_landing_client_id: {
63
84
  type: 'string',
64
85
  description: 'The client ID of the invitation landing client for the My Organization configuration',
@@ -36,6 +36,33 @@ export declare const schema: {
36
36
  connection_config: {
37
37
  type: string[];
38
38
  };
39
+ cross_app_access_resource_app: {
40
+ type: string;
41
+ description: string;
42
+ properties: {
43
+ status: {
44
+ type: string;
45
+ properties: {
46
+ default_value: {
47
+ type: string;
48
+ enum: ("enabled" | "disabled")[];
49
+ description: string;
50
+ };
51
+ allowed_values: {
52
+ type: string;
53
+ items: {
54
+ type: string;
55
+ enum: ("enabled" | "disabled")[];
56
+ };
57
+ uniqueItems: boolean;
58
+ description: string;
59
+ };
60
+ };
61
+ required: string[];
62
+ };
63
+ };
64
+ required: string[];
65
+ };
39
66
  strategy_overrides: {
40
67
  type: string[];
41
68
  properties: {
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getConnectionProfile = exports.schema = void 0;
7
+ const auth0_1 = require("auth0");
7
8
  const default_1 = __importDefault(require("./default"));
8
9
  const client_1 = require("../client");
9
10
  const logger_1 = __importDefault(require("../../../logger"));
@@ -42,6 +43,35 @@ exports.schema = {
42
43
  connection_config: {
43
44
  type: ['object', 'null'],
44
45
  },
46
+ cross_app_access_resource_app: {
47
+ type: 'object',
48
+ description: 'Controls whether organization admins may enable Cross App Access (XAA) on their Identity Providers.',
49
+ properties: {
50
+ status: {
51
+ type: 'object',
52
+ properties: {
53
+ default_value: {
54
+ type: 'string',
55
+ enum: Object.values(auth0_1.Management.ConnectionProfileCrossAppAccessResourceAppStatusDefaultValueEnum),
56
+ description: 'The default Cross App Access resource app status.',
57
+ },
58
+ allowed_values: {
59
+ type: 'array',
60
+ items: {
61
+ type: 'string',
62
+ enum: Object.values(auth0_1.Management.ConnectionProfileCrossAppAccessResourceAppStatusValueEnum),
63
+ },
64
+ uniqueItems: true,
65
+ description: 'The allowed Cross App Access resource app status values.',
66
+ },
67
+ },
68
+ // allowed_values is optional per the SDK type (unlike the client's
69
+ // third_party_client_access, which requires both fields).
70
+ required: ['default_value'],
71
+ },
72
+ },
73
+ required: ['status'],
74
+ },
45
75
  strategy_overrides: {
46
76
  type: ['object', 'null'],
47
77
  properties: {
@@ -380,12 +380,15 @@ class ConnectionsHandler extends default_1.default {
380
380
  }
381
381
  getFormattedOptions(connection, clients) {
382
382
  try {
383
+ const { idpinitiated } = connection.options;
383
384
  return {
384
385
  options: {
385
386
  ...connection.options,
386
387
  idpinitiated: {
387
- ...connection.options.idpinitiated,
388
- client_id: (0, utils_1.convertClientNameToId)(connection.options.idpinitiated.client_id, clients),
388
+ ...idpinitiated,
389
+ ...(idpinitiated.client_id && {
390
+ client_id: (0, utils_1.convertClientNameToId)(idpinitiated.client_id, clients),
391
+ }),
389
392
  },
390
393
  },
391
394
  };
@@ -744,7 +747,11 @@ class ConnectionsHandler extends default_1.default {
744
747
  ...(enabledClients !== undefined && { enabled_clients: enabledClients }),
745
748
  };
746
749
  });
747
- const proposedChanges = await super.dryRunChanges({ ...assets, connections: formatted });
750
+ let proposedChanges = await super.dryRunChanges({ ...assets, connections: formatted });
751
+ const includedConnections = (assets.include && assets.include.connections) || [];
752
+ const excludedConnections = (assets.exclude && assets.exclude.connections) || [];
753
+ proposedChanges = (0, utils_1.filterExcluded)(proposedChanges, excludedConnections);
754
+ proposedChanges = (0, utils_1.filterIncluded)(proposedChanges, includedConnections);
748
755
  return (0, exports.addExcludedConnectionPropertiesToChanges)({
749
756
  proposedChanges,
750
757
  existingConnections,
@@ -554,7 +554,7 @@ class DatabaseHandler extends default_1.default {
554
554
  assets: formatted,
555
555
  existing: existingDatabasesConnections,
556
556
  identifiers: this.identifiers,
557
- ignoreDryRunFields: this.ignoreDryRunFields,
557
+ ignoreDryRunFields: this.getEffectiveIgnoreDryRunFields(),
558
558
  });
559
559
  }
560
560
  // Run after clients are updated so we can convert all the enabled_clients names to id's
@@ -1,6 +1,20 @@
1
1
  import { Asset, Assets, Auth0APIClient, CalculatedChanges } from '../../../types';
2
2
  import { ConfigFunction } from '../../../configFactory';
3
3
  export declare function order(value: any): (t: any, n: any, descriptor: any) => any;
4
+ export interface RetryOptions {
5
+ maxRetries?: number;
6
+ initialDelay?: number;
7
+ maxDelay?: number;
8
+ onRetry?: (error: any, attempt: number, delay: number) => void;
9
+ }
10
+ /**
11
+ * Executes a function with exponential backoff retry logic for rate limit errors (429).
12
+ *
13
+ * @param fn - The function to execute with retry logic
14
+ * @param options - Configuration options for retry behavior
15
+ * @returns Promise that resolves with the function result or rejects after max retries
16
+ */
17
+ export declare function retryWithExponentialBackoff<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
4
18
  type ApiMethodOverride = string | Function;
5
19
  export default class APIHandler {
6
20
  config: ConfigFunction;
@@ -50,6 +64,15 @@ export default class APIHandler {
50
64
  */
51
65
  getEffectiveIgnoreDryRunFields(): string[];
52
66
  getClientFN(fn: ApiMethodOverride): Function;
67
+ /**
68
+ * Builds the exponential-backoff retry configuration for this handler from the
69
+ * shared `AUTH0_MAX_RETRIES` / `AUTH0_RETRY_INITIAL_DELAY_MS` /
70
+ * `AUTH0_RETRY_MAX_DELAY_MS` config keys. Exposed so that handlers which issue
71
+ * writes outside the default `processChanges` flow (e.g. organizations, which
72
+ * writes nested connections/grants directly) can wrap those calls in the same
73
+ * 429 backoff behaviour as the base handler.
74
+ */
75
+ getRetryConfig(): RetryOptions;
53
76
  didDelete(item: Asset): void;
54
77
  didCreate(item: Asset): void;
55
78
  didUpdate(item: Asset): void;
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.order = order;
7
+ exports.retryWithExponentialBackoff = retryWithExponentialBackoff;
7
8
  const auth0_1 = require("auth0");
8
9
  const validationError_1 = __importDefault(require("../../validationError"));
9
10
  const utils_1 = require("../../utils");
@@ -114,6 +115,25 @@ class APIHandler {
114
115
  }
115
116
  return fn;
116
117
  }
118
+ /**
119
+ * Builds the exponential-backoff retry configuration for this handler from the
120
+ * shared `AUTH0_MAX_RETRIES` / `AUTH0_RETRY_INITIAL_DELAY_MS` /
121
+ * `AUTH0_RETRY_MAX_DELAY_MS` config keys. Exposed so that handlers which issue
122
+ * writes outside the default `processChanges` flow (e.g. organizations, which
123
+ * writes nested connections/grants directly) can wrap those calls in the same
124
+ * 429 backoff behaviour as the base handler.
125
+ */
126
+ getRetryConfig() {
127
+ const retryConfig = {
128
+ maxRetries: this.config('AUTH0_MAX_RETRIES') || DEFAULT_MAX_RETRIES,
129
+ initialDelay: this.config('AUTH0_RETRY_INITIAL_DELAY_MS') || DEFAULT_INITIAL_DELAY_MS,
130
+ maxDelay: this.config('AUTH0_RETRY_MAX_DELAY_MS') || DEFAULT_MAX_DELAY_MS,
131
+ onRetry: (error, attempt, delay) => {
132
+ logger_1.default.warn(`Rate limit hit for [${this.type}]. Retrying attempt ${attempt}/${retryConfig.maxRetries} after ${Math.round(delay / 1000)}s...`);
133
+ },
134
+ };
135
+ return retryConfig;
136
+ }
117
137
  didDelete(item) {
118
138
  logger_1.default.info(`Deleted [${this.type}]: ${this.objString(item)}`);
119
139
  }
@@ -210,7 +230,6 @@ class APIHandler {
210
230
  return (0, calculateDryRunChanges_1.calculateDryRunChanges)({
211
231
  type: this.type,
212
232
  assets: typeAssets,
213
- // @ts-ignore TODO: investigate what happens when `existing` is null
214
233
  existing,
215
234
  identifiers: this.identifiers,
216
235
  ignoreDryRunFields: this.getEffectiveIgnoreDryRunFields(),
@@ -243,7 +262,6 @@ class APIHandler {
243
262
  return (0, calculateDryRunChanges_1.calculateDryRunChanges)({
244
263
  type: this.type,
245
264
  assets: typeAssets,
246
- // @ts-ignore TODO: investigate what happens when `existing` is null
247
265
  existing,
248
266
  identifiers: this.identifiers,
249
267
  ignoreDryRunFields: this.getEffectiveIgnoreDryRunFields(),
@@ -282,14 +300,7 @@ class APIHandler {
282
300
  const conflicts = changes.conflicts || [];
283
301
  logger_1.default.debug(`Start processChanges for ${this.type} [delete:${del.length}] [update:${update.length}], [create:${create.length}], [conflicts:${conflicts.length}]`);
284
302
  // Set retry configuration from config
285
- const retryConfig = {
286
- maxRetries: this.config('AUTH0_MAX_RETRIES') || DEFAULT_MAX_RETRIES,
287
- initialDelay: this.config('AUTH0_RETRY_INITIAL_DELAY_MS') || DEFAULT_INITIAL_DELAY_MS,
288
- maxDelay: this.config('AUTH0_RETRY_MAX_DELAY_MS') || DEFAULT_MAX_DELAY_MS,
289
- onRetry: (error, attempt, delay) => {
290
- logger_1.default.warn(`Rate limit hit for [${this.type}]. Retrying attempt ${attempt}/${retryConfig.maxRetries} after ${Math.round(delay / 1000)}s...`);
291
- },
292
- };
303
+ const retryConfig = this.getRetryConfig();
293
304
  // Process Deleted
294
305
  if (del.length > 0) {
295
306
  const allowDelete = this.config('AUTH0_ALLOW_DELETE') === 'true' || this.config('AUTH0_ALLOW_DELETE') === true;
@@ -147,7 +147,7 @@ export default class OrganizationsHandler extends DefaultHandler {
147
147
  createOrganizations(creates: CalculatedChanges['create']): Promise<void>;
148
148
  updateOrganization(org: any, organizations: any): Promise<{
149
149
  id: any;
150
- }>;
150
+ } | null>;
151
151
  getClientGrantIDByClientName(clientsName: string): string;
152
152
  getClientIdByClientName(clientName: string): string;
153
153
  getFormattedClientGrants(): Promise<FormattedClientGrants[]>;
@@ -194,8 +194,9 @@ class OrganizationsHandler extends default_1.default {
194
194
  throw new Error(`Organization "${organization.name}" was created but the response did not include an ID. Skipping connection/grant association.`);
195
195
  }
196
196
  const createdId = created.id;
197
+ const retryConfig = this.getRetryConfig();
197
198
  if (typeof org.connections !== 'undefined' && org.connections.length > 0) {
198
- await Promise.all(org.connections.map((conn) => this.client.organizations.connections.create(createdId, conn)));
199
+ await Promise.all(org.connections.map((conn) => (0, default_1.retryWithExponentialBackoff)(() => this.client.organizations.connections.create(createdId, conn), retryConfig)));
199
200
  }
200
201
  if (typeof org.client_grants !== 'undefined' && org.client_grants.length > 0) {
201
202
  await Promise.all(org.client_grants.map((organizationClientGrants) => this.createOrganizationClientGrants(createdId, this.getClientGrantIDByClientName(organizationClientGrants.client_id))));
@@ -240,7 +241,8 @@ class OrganizationsHandler extends default_1.default {
240
241
  .promise();
241
242
  }
242
243
  async updateOrganization(org, organizations) {
243
- const { connections: existingConnections, client_grants: existingClientGrants, discovery_domains: existingDiscoveryDomains, clients: existingOrgClients = [], } = await organizations.find((orgToUpdate) => orgToUpdate.name === org.name);
244
+ const existingOrg = organizations.find((orgToUpdate) => orgToUpdate.name === org.name);
245
+ const { connections: existingConnections, client_grants: existingClientGrants, discovery_domains: existingDiscoveryDomains, clients: existingOrgClients = [], } = existingOrg;
244
246
  const params = { id: org.id };
245
247
  const { connections, client_grants: organizationClientGrants, discovery_domains: organizationDiscoveryDomains, clients: organizationClients, } = org;
246
248
  delete org.connections;
@@ -249,7 +251,14 @@ class OrganizationsHandler extends default_1.default {
249
251
  delete org.client_grants;
250
252
  delete org.discovery_domains;
251
253
  delete org.clients;
252
- await this.client.organizations.update(params.id, org);
254
+ // Only PATCH if top-level properties actually differ from the existing state.
255
+ // Compare only the keys present in the desired config against those same keys
256
+ // on the remote org, so that fields the user didn't specify are not considered.
257
+ let changed = false;
258
+ if (!(0, lodash_1.isEqual)(org, (0, lodash_1.pick)(existingOrg, Object.keys(org)))) {
259
+ await this.client.organizations.update(params.id, org);
260
+ changed = true;
261
+ }
253
262
  // organization connections
254
263
  const connectionsToRemove = existingConnections.filter((c) => !connections.find((x) => x.connection_id === c.connection_id));
255
264
  const connectionsToAdd = connections.filter((c) => !existingConnections.find((x) => x.connection_id === c.connection_id));
@@ -260,27 +269,27 @@ class OrganizationsHandler extends default_1.default {
260
269
  x.organization_access_level !== c.organization_access_level ||
261
270
  x.organization_connection_name !== c.organization_connection_name ||
262
271
  x.is_enabled !== (c.is_enabled ?? true))));
272
+ if (connectionsToUpdate.length > 0 ||
273
+ connectionsToAdd.length > 0 ||
274
+ connectionsToRemove.length > 0) {
275
+ changed = true;
276
+ }
277
+ const retryConfig = this.getRetryConfig();
263
278
  // Handle updates first
264
- await Promise.all(connectionsToUpdate.map((conn) => this.client.organizations.connections
265
- .update(params.id, conn.connection_id, {
279
+ await Promise.all(connectionsToUpdate.map((conn) => (0, default_1.retryWithExponentialBackoff)(() => this.client.organizations.connections.update(params.id, conn.connection_id, {
266
280
  organization_connection_name: conn.organization_connection_name,
267
281
  assign_membership_on_login: conn.assign_membership_on_login,
268
282
  show_as_button: conn.show_as_button,
269
283
  is_signup_enabled: conn.is_signup_enabled,
270
284
  is_enabled: conn.is_enabled,
271
285
  organization_access_level: conn.organization_access_level,
272
- })
273
- .catch(() => {
286
+ }), retryConfig).catch(() => {
274
287
  throw new Error(`Problem updating Enabled Connection ${conn.connection_id} for organizations ${params.id}`);
275
288
  })));
276
- await Promise.all(connectionsToAdd.map((conn) => this.client.organizations.connections
277
- .create(params.id, (0, lodash_1.omit)(conn, 'connection'))
278
- .catch(() => {
289
+ await Promise.all(connectionsToAdd.map((conn) => (0, default_1.retryWithExponentialBackoff)(() => this.client.organizations.connections.create(params.id, (0, lodash_1.omit)(conn, 'connection')), retryConfig).catch(() => {
279
290
  throw new Error(`Problem adding Enabled Connection ${conn.connection_id} for organizations ${params.id}`);
280
291
  })));
281
- await Promise.all(connectionsToRemove.map((conn) => this.client.organizations.connections
282
- .delete(params.id, conn.connection_id)
283
- .catch(() => {
292
+ await Promise.all(connectionsToRemove.map((conn) => (0, default_1.retryWithExponentialBackoff)(() => this.client.organizations.connections.delete(params.id, conn.connection_id), retryConfig).catch(() => {
284
293
  throw new Error(`Problem removing Enabled Connection ${conn.connection_id} for organizations ${params.id}`);
285
294
  })));
286
295
  // organization client_grants
@@ -294,6 +303,9 @@ class OrganizationsHandler extends default_1.default {
294
303
  ?.map((clientGrant) => ({
295
304
  grant_id: this.getClientGrantIDByClientName(clientGrant.client_id),
296
305
  })) || [];
306
+ if (orgClientGrantsToAdd.length > 0 || orgClientGrantsToRemove.length > 0) {
307
+ changed = true;
308
+ }
297
309
  // Handle updates first
298
310
  await Promise.all(orgClientGrantsToAdd.map((orgClientGrant) => this.createOrganizationClientGrants(params.id, orgClientGrant.grant_id).catch(() => {
299
311
  throw new Error(`Problem adding organization clientGrant ${orgClientGrant.grant_id} for organizations ${params.id}`);
@@ -315,6 +327,9 @@ class OrganizationsHandler extends default_1.default {
315
327
  };
316
328
  })
317
329
  .filter(Boolean) || [];
330
+ if (orgDiscoveryDomainsToUpdate.length > 0 || orgDiscoveryDomainsToAdd.length > 0) {
331
+ changed = true;
332
+ }
318
333
  for (const { id, domain, ...updateParams } of orgDiscoveryDomainsToUpdate) {
319
334
  try {
320
335
  await this.updateOrganizationDiscoveryDomain(params.id, id, domain, updateParams);
@@ -338,6 +353,7 @@ class OrganizationsHandler extends default_1.default {
338
353
  if (orgDiscoveryDomainsToRemove.length > 0) {
339
354
  if (this.config('AUTH0_ALLOW_DELETE') === 'true' ||
340
355
  this.config('AUTH0_ALLOW_DELETE') === true) {
356
+ changed = true;
341
357
  for (const domain of orgDiscoveryDomainsToRemove) {
342
358
  try {
343
359
  await this.deleteOrganizationDiscoveryDomain(params.id, domain.domain, domain.id);
@@ -375,6 +391,7 @@ class OrganizationsHandler extends default_1.default {
375
391
  }))
376
392
  .filter((oc) => !!oc.client_id);
377
393
  if (orgClientsToAdd.length > 0) {
394
+ changed = true;
378
395
  await this.createOrganizationClients(params.id, orgClientsToAdd).catch((err) => {
379
396
  throw new Error(`Problem adding org clients for organization ${params.id}\n${err}`);
380
397
  });
@@ -382,6 +399,7 @@ class OrganizationsHandler extends default_1.default {
382
399
  if (orgClientsToRemove.length > 0) {
383
400
  if (this.config('AUTH0_ALLOW_DELETE') === 'true' ||
384
401
  this.config('AUTH0_ALLOW_DELETE') === true) {
402
+ changed = true;
385
403
  await this.deleteOrganizationClients(params.id, orgClientsToRemove).catch((err) => {
386
404
  throw new Error(`Problem removing org clients for organization ${params.id}\n${err}`);
387
405
  });
@@ -390,6 +408,9 @@ class OrganizationsHandler extends default_1.default {
390
408
  logger_1.default.warn(`Detected the following organization client associations should be removed. Doing so may be destructive.\nYou can enable deletes by setting 'AUTH0_ALLOW_DELETE' to true in the config\n${orgClientsToRemove.join('\n')}`);
391
409
  }
392
410
  }
411
+ if (orgClientsToUpdate.length > 0) {
412
+ changed = true;
413
+ }
393
414
  await Promise.all(orgClientsToUpdate.map((oc) => this.client.organizations.clients
394
415
  .update(params.id, oc.client_id, {
395
416
  use_for_member_access: oc.use_for_member_access,
@@ -397,7 +418,7 @@ class OrganizationsHandler extends default_1.default {
397
418
  .catch((err) => {
398
419
  throw new Error(`Problem updating org client ${oc.client_id} for organization ${params.id}\n${err}`);
399
420
  })));
400
- return params;
421
+ return changed ? params : null;
401
422
  }
402
423
  getClientGrantIDByClientName(clientsName) {
403
424
  const found = this.formattedClientGrants.find((c) => c.client_id === clientsName);
@@ -435,8 +456,10 @@ class OrganizationsHandler extends default_1.default {
435
456
  data: updates || [],
436
457
  generator: (item) => this.updateOrganization(item, orgs)
437
458
  .then((data) => {
438
- this.didUpdate(data);
439
- this.updated += 1;
459
+ if (data) {
460
+ this.didUpdate(data);
461
+ this.updated += 1;
462
+ }
440
463
  })
441
464
  .catch((err) => {
442
465
  throw new Error(`Problem updating ${this.type} ${this.objString(item)}\n${err}`);
@@ -202,12 +202,20 @@ class PhoneProviderHandler extends default_1.default {
202
202
  }
203
203
  const currentProviders = await this.getPhoneProviders();
204
204
  const providerReqPayload = (() => {
205
+ // Work on a shallow copy so we don't mutate the caller's asset (phoneProviders[0]).
206
+ // The SDK response type doesn't declare `credentials`, so widen it here.
207
+ const payload = { ...phoneProviders[0] };
205
208
  // Removing id from update and create payloads, otherwise API will error
206
209
  // id may be required to handle if `--export_ids=true`
207
- const payload = phoneProviders[0];
208
- if (payload && 'id' in payload) {
210
+ if ('id' in payload) {
209
211
  delete payload.id;
210
212
  }
213
+ // The `custom` provider requires a `credentials` object in the payload even
214
+ // when it is empty. Exported configs omit credentials, so inject an empty
215
+ // object here to avoid a "Missing required property: credentials" 400 error.
216
+ if (payload.name === 'custom' && payload.credentials == null) {
217
+ payload.credentials = {};
218
+ }
211
219
  return payload;
212
220
  })();
213
221
  if (currentProviders === null || currentProviders.length === 0) {
@@ -108,6 +108,13 @@ function normalizeArrayValues(values) {
108
108
  .map((value) => normalizeValue(value))
109
109
  .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
110
110
  }
111
+ /**
112
+ * Serializes a value to a human-readable string for use in diff messages.
113
+ * Objects and arrays are JSON-stringified to avoid "[object Object]" output.
114
+ */
115
+ function formatDiffValue(value) {
116
+ return typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
117
+ }
111
118
  /**
112
119
  * Writes the accumulated diff log to a JSON file on disk.
113
120
  * Useful for CI pipelines that want a machine-readable dry-run report.
@@ -179,7 +186,7 @@ function getObjectDifferences(localObj, remoteObj, keyObjPath = '', resourceType
179
186
  differences.push(...nestedDifferences);
180
187
  }
181
188
  else if (item !== normalizedRemoteValue[index]) {
182
- const message = `Array item difference at [${currentPath}[${index}]]: local:${item} vs remote:${normalizedRemoteValue[index]}`;
189
+ const message = `Array item difference at [${currentPath}[${index}]]: local:${formatDiffValue(item)} vs remote:${formatDiffValue(normalizedRemoteValue[index])}`;
183
190
  differences.push(message);
184
191
  }
185
192
  });
@@ -204,13 +211,10 @@ function getObjectDifferences(localObj, remoteObj, keyObjPath = '', resourceType
204
211
  differences.push(...nestedDifferences);
205
212
  return;
206
213
  }
207
- // Compare primitive values — omit array indices from path to reduce log noise
214
+ // Compare primitive values
208
215
  if (localValue !== remoteValue) {
209
- let arrayPathRegex = new RegExp(/\[\d+\]/g);
210
- if (!arrayPathRegex.test(currentPath)) {
211
- const message = `Value difference for [${currentPath}]: local:${localValue} vs remote:${remoteValue}`;
212
- differences.push(message);
213
- }
216
+ const message = `Value difference for [${currentPath}]: local:${localValue} vs remote:${remoteValue}`;
217
+ differences.push(message);
214
218
  }
215
219
  });
216
220
  return differences;
@@ -242,7 +246,7 @@ function calculateDryRunChanges({ type, assets, existing, identifiers = ['id', '
242
246
  const create = [];
243
247
  const conflicts = [];
244
248
  const localAssets = (Array.isArray(assets) ? [...assets] : [assets]).map((asset) => type === 'tenant' ? normalizeTenantForDryRun(asset) : asset);
245
- const remoteAssets = (Array.isArray(existing) ? [...existing] : [existing]).map((asset) => (type === 'tenant' ? normalizeTenantForDryRun(asset) : asset));
249
+ const remoteAssets = (Array.isArray(existing) ? [...existing] : existing ? [existing] : []).map((asset) => (type === 'tenant' ? normalizeTenantForDryRun(asset) : asset));
246
250
  // Helper: returns true if a local and remote asset share at least one identifier value
247
251
  const assetsMatch = (localAsset, remoteAsset) => identifiers.some((id) => {
248
252
  if (Array.isArray(id)) {
package/lib/utils.js CHANGED
@@ -301,12 +301,15 @@ exports.decodeBase64ToCertString = decodeBase64ToCertString;
301
301
  // Format connection options by converting client IDs to client names for SAML connections
302
302
  const getFormattedOptions = (connection, clients) => {
303
303
  try {
304
+ const { idpinitiated } = connection.options;
304
305
  return {
305
306
  options: {
306
307
  ...connection.options,
307
308
  idpinitiated: {
308
- ...connection.options.idpinitiated,
309
- client_id: convertClientIdToName(connection.options.idpinitiated.client_id, clients),
309
+ ...idpinitiated,
310
+ ...(idpinitiated.client_id && {
311
+ client_id: convertClientIdToName(idpinitiated.client_id, clients),
312
+ }),
310
313
  },
311
314
  },
312
315
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auth0-deploy-cli",
3
- "version": "8.43.0",
3
+ "version": "8.44.0",
4
4
  "description": "A command line tool for deploying updates to your Auth0 tenant",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -35,7 +35,7 @@
35
35
  "dependencies": {
36
36
  "@clack/prompts": "1.4.0",
37
37
  "ajv": "^6.12.6",
38
- "auth0": "^6.2.0",
38
+ "auth0": "^6.3.0",
39
39
  "chalk": "5.6.2",
40
40
  "dot-prop": "^5.3.0",
41
41
  "fs-extra": "^10.1.0",