auth0-deploy-cli 9.0.0 → 9.1.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/CHANGELOG.md CHANGED
@@ -7,13 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [9.0.0] - 2026-09-18
10
+ ## [9.1.0] - 2026-10-07
11
11
 
12
- ### Changed
12
+ ### Added
13
13
 
14
- - **⚠️ Breaking changes:**
15
- - **Config file handlers (import): path traversal is now a hard error.** A file reference that resolves outside the config directory now aborts the import with an error instead of logging a deprecation warning (the warning shipped in 8.43.0 and 8.44.0). This applies to every file-backed field in both directory and YAML formats: action `code`, rule/hook `script`, database `customScripts`, client `custom_login_page`, connection/email-template/branding `body`, prompt partials, and flow/form bodies. Move any out-of-tree references (`../` relative paths or absolute paths) inside your config directory. Blocked references fail with `Path traversal blocked: "<input>" resolves to "<resolved>" which is outside the config directory "<root>".` before any Management API call. `a0deploy export` is unaffected. [#1498]
16
- - **`AUTH0_DOMAIN` environment variable: must now be a bare host.** The `node-auth0` v7 upgrade validates the domain more strictly and rejects values that include a scheme or a trailing slash (for example `https://tenant.auth0.com/`); use a bare host such as `tenant.us.auth0.com`. This Management-API-only release also moves token acquisition to the SDK's managed auth, which auto-refreshes tokens during long-running import/export runs while preserving the previous fail-fast behavior on bad credentials. See the [node-auth0 v7 migration guide](https://github.com/auth0/node-auth0/blob/master/v7_MIGRATION_GUIDE.md). [#1491]
14
+ - Add support for the `auth_email_by_code` email template. [#1500]
15
+ - Add support for phone provider protection (`phoneProviderProtection`) to the `attackProtection` resource. [#1503]
16
+ - Support the `require_consent_non_repudiation` resource server property and the `my-account` client `async_approval_notification_channels` value (EA). [#1508]
17
+ - Support `!include` directives in YAML config files, including nested includes, so a large `tenant.yaml` can be split across multiple files. [#1252]
18
+ - Add `AUTH0_EXPORT_CUSTOM_TEXT_LANGUAGES` config to limit which languages' prompts custom text is fetched, shortening exports on tenants with many languages enabled. Behaviour is unchanged when not set. [#1511]
19
+
20
+ ### Fixed
21
+
22
+ - Speed up imports into large tenants by enriching connection `enabled_clients` only for connections in the local config and skipping the SCIM id map when SCIM is not enabled. [#1505]
23
+ - Allow system resource servers other than the Auth0 My Account API (e.g. the Auth0 My Organization API) to be updated. [#1485]
24
+ - Prevent roles from losing permissions on an interrupted import by diffing permissions instead of clearing and re-adding them, and retrying role updates on 429 responses. [#1510]
25
+ - Reduce `deploy`/`import` memory use and startup time by defining the `prompts.customText` JSON schema once and referencing it per language and prompt, instead of expanding it for every language × prompt × screen combination. [#1509]
26
+
27
+ ## [9.0.0] - 2026-09-18
17
28
 
18
29
  ### Added
19
30
 
@@ -23,6 +34,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
23
34
  - Support `enforce_permission_ceiling` and `enforce_self_assignment_restriction` on `my_organization_configuration` (EA). [#1495]
24
35
  - Support tenants without the `riskAssessment` and `guardianPolicies` entitlement by handling `insufficient_entitlement` 403 responses gracefully instead of failing the run. [#1493]
25
36
 
37
+ ### Changed
38
+
39
+ - **⚠️ Breaking changes:**
40
+ - **Config file handlers (import): path traversal is now a hard error.** A file reference that resolves outside the config directory now aborts the import with an error instead of logging a deprecation warning (the warning shipped in 8.43.0 and 8.44.0). This applies to every file-backed field in both directory and YAML formats: action `code`, rule/hook `script`, database `customScripts`, client `custom_login_page`, connection/email-template/branding `body`, prompt partials, and flow/form bodies. Move any out-of-tree references (`../` relative paths or absolute paths) inside your config directory. Blocked references fail with `Path traversal blocked: "<input>" resolves to "<resolved>" which is outside the config directory "<root>".` before any Management API call. `a0deploy export` is unaffected. [#1498]
41
+ - **`AUTH0_DOMAIN` environment variable: must now be a bare host.** The `node-auth0` v7 upgrade validates the domain more strictly and rejects values that include a scheme or a trailing slash (for example `https://tenant.auth0.com/`); use a bare host such as `tenant.us.auth0.com`. This Management-API-only release also moves token acquisition to the SDK's managed auth, which auto-refreshes tokens during long-running import/export runs while preserving the previous fail-fast behavior on bad credentials. See the [node-auth0 v7 migration guide](https://github.com/auth0/node-auth0/blob/master/v7_MIGRATION_GUIDE.md). [#1491]
42
+
26
43
  ### Fixed
27
44
 
28
45
  - Surface the real API error on `tokenExchangeProfiles` 403 responses. [#1492]
@@ -1898,6 +1915,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1898
1915
  [#1242]: https://github.com/auth0/auth0-deploy-cli/issues/1242
1899
1916
  [#1244]: https://github.com/auth0/auth0-deploy-cli/issues/1244
1900
1917
  [#1246]: https://github.com/auth0/auth0-deploy-cli/issues/1246
1918
+ [#1252]: https://github.com/auth0/auth0-deploy-cli/issues/1252
1901
1919
  [#1253]: https://github.com/auth0/auth0-deploy-cli/issues/1253
1902
1920
  [#1261]: https://github.com/auth0/auth0-deploy-cli/issues/1261
1903
1921
  [#1263]: https://github.com/auth0/auth0-deploy-cli/issues/1263
@@ -2000,6 +2018,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2000
2018
  [#1481]: https://github.com/auth0/auth0-deploy-cli/issues/1481
2001
2019
  [#1482]: https://github.com/auth0/auth0-deploy-cli/issues/1482
2002
2020
  [#1483]: https://github.com/auth0/auth0-deploy-cli/issues/1483
2021
+ [#1485]: https://github.com/auth0/auth0-deploy-cli/issues/1485
2003
2022
  [#1486]: https://github.com/auth0/auth0-deploy-cli/issues/1486
2004
2023
  [#1487]: https://github.com/auth0/auth0-deploy-cli/issues/1487
2005
2024
  [#1489]: https://github.com/auth0/auth0-deploy-cli/issues/1489
@@ -2011,7 +2030,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2011
2030
  [#1496]: https://github.com/auth0/auth0-deploy-cli/issues/1496
2012
2031
  [#1497]: https://github.com/auth0/auth0-deploy-cli/issues/1497
2013
2032
  [#1498]: https://github.com/auth0/auth0-deploy-cli/issues/1498
2014
- [Unreleased]: https://github.com/auth0/auth0-deploy-cli/compare/v9.0.0...HEAD
2033
+ [#1500]: https://github.com/auth0/auth0-deploy-cli/issues/1500
2034
+ [#1503]: https://github.com/auth0/auth0-deploy-cli/issues/1503
2035
+ [#1505]: https://github.com/auth0/auth0-deploy-cli/issues/1505
2036
+ [#1509]: https://github.com/auth0/auth0-deploy-cli/issues/1509
2037
+ [#1508]: https://github.com/auth0/auth0-deploy-cli/issues/1508
2038
+ [#1510]: https://github.com/auth0/auth0-deploy-cli/issues/1510
2039
+ [#1511]: https://github.com/auth0/auth0-deploy-cli/issues/1511
2040
+ [Unreleased]: https://github.com/auth0/auth0-deploy-cli/compare/v9.1.0...HEAD
2041
+ [9.1.0]: https://github.com/auth0/auth0-deploy-cli/compare/v9.0.0...v9.1.0
2015
2042
  [9.0.0]: https://github.com/auth0/auth0-deploy-cli/compare/v8.45.0...v9.0.0
2016
2043
  [8.45.0]: https://github.com/auth0/auth0-deploy-cli/compare/v8.44.0...v8.45.0
2017
2044
  [8.44.0]: https://github.com/auth0/auth0-deploy-cli/compare/v8.43.0...v8.44.0
@@ -17,6 +17,7 @@ function attackProtectionFiles(filePath) {
17
17
  bruteForceProtection: path_1.default.join(directory, 'brute-force-protection.json'),
18
18
  captcha: path_1.default.join(directory, 'captcha.json'),
19
19
  suspiciousIpThrottling: path_1.default.join(directory, 'suspicious-ip-throttling.json'),
20
+ phoneProviderProtection: path_1.default.join(directory, 'phone-provider-protection.json'),
20
21
  };
21
22
  }
22
23
  function parse(context) {
@@ -55,6 +56,12 @@ function parse(context) {
55
56
  disableKeywordReplacement: context.disableKeywordReplacement,
56
57
  });
57
58
  }
59
+ if ((0, utils_1.isFile)(files.phoneProviderProtection)) {
60
+ attackProtection.phoneProviderProtection = (0, utils_1.loadJSON)(files.phoneProviderProtection, {
61
+ mappings: context.mappings,
62
+ disableKeywordReplacement: context.disableKeywordReplacement,
63
+ });
64
+ }
58
65
  return {
59
66
  attackProtection,
60
67
  };
@@ -81,6 +88,9 @@ async function dump(context) {
81
88
  if (maskedAttackProtection.suspiciousIpThrottling) {
82
89
  (0, utils_1.dumpJSON)(files.suspiciousIpThrottling, maskedAttackProtection.suspiciousIpThrottling);
83
90
  }
91
+ if (maskedAttackProtection.phoneProviderProtection) {
92
+ (0, utils_1.dumpJSON)(files.phoneProviderProtection, maskedAttackProtection.phoneProviderProtection);
93
+ }
84
94
  }
85
95
  const attackProtectionHandler = {
86
96
  parse,
@@ -27,6 +27,7 @@ const nonPrimitiveProps = [
27
27
  'EXCLUDED_PROPS',
28
28
  'INCLUDED_PROPS',
29
29
  'AUTH0_INCLUDED_CONNECTIONS',
30
+ 'AUTH0_EXPORT_CUSTOM_TEXT_LANGUAGES',
30
31
  ];
31
32
  const EA_FEATURES = [];
32
33
  const setupContext = async (config, command) => {
@@ -13,7 +13,7 @@ async function dump(context) {
13
13
  const { attackProtection } = context.assets;
14
14
  if (!attackProtection)
15
15
  return { attackProtection: null };
16
- const { botDetection, suspiciousIpThrottling, breachedPasswordDetection, bruteForceProtection, captcha, } = attackProtection;
16
+ const { botDetection, suspiciousIpThrottling, breachedPasswordDetection, bruteForceProtection, captcha, phoneProviderProtection, } = attackProtection;
17
17
  const attackProtectionConfig = {
18
18
  suspiciousIpThrottling,
19
19
  breachedPasswordDetection,
@@ -25,6 +25,9 @@ async function dump(context) {
25
25
  if (captcha) {
26
26
  attackProtectionConfig.captcha = captcha;
27
27
  }
28
+ if (phoneProviderProtection) {
29
+ attackProtectionConfig.phoneProviderProtection = phoneProviderProtection;
30
+ }
28
31
  const maskedAttackProtection = (0, defaults_1.attackProtectionDefaults)(attackProtectionConfig, context.config);
29
32
  return {
30
33
  attackProtection: (0, defaults_1.sortAttackProtectionArrays)(maskedAttackProtection),
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const fs_extra_1 = __importDefault(require("fs-extra"));
7
7
  const js_yaml_1 = __importDefault(require("js-yaml"));
8
8
  const path_1 = __importDefault(require("path"));
9
+ const lodash_1 = require("lodash");
9
10
  const tools_1 = require("../../tools");
10
11
  const client_1 = __importDefault(require("../../tools/auth0/client"));
11
12
  const logger_1 = __importDefault(require("../../logger"));
@@ -14,6 +15,83 @@ const handlers_1 = __importDefault(require("./handlers"));
14
15
  const readonly_1 = __importDefault(require("../../readonly"));
15
16
  const __1 = require("..");
16
17
  const keywordPreservation_1 = require("../../keywordPreservation");
18
+ // Symbol marker so a literal `__include` key in user YAML isn't read as a directive
19
+ const INCLUDE = Symbol('include');
20
+ // Custom YAML type for file includes
21
+ const includeType = new js_yaml_1.default.Type('!include', {
22
+ kind: 'scalar',
23
+ resolve: (data) => typeof data === 'string',
24
+ construct: (data) => {
25
+ // This will be handled during the actual loading process
26
+ return { [INCLUDE]: data };
27
+ },
28
+ });
29
+ const schema = js_yaml_1.default.DEFAULT_SCHEMA.extend([includeType]);
30
+ function prepareYamlContent(content, mappings, disableKeywordReplacement) {
31
+ return disableKeywordReplacement
32
+ ? (0, tools_1.wrapArrayReplaceMarkersInQuotes)(content, mappings)
33
+ : (0, tools_1.keywordReplace)(content, mappings);
34
+ }
35
+ function parseYaml(content) {
36
+ return js_yaml_1.default.load(content, { schema });
37
+ }
38
+ // Resolves !include directives. Keyword handling is applied by the caller before parse.
39
+ // configRoot is threaded unchanged so chained `../` includes can't escape it.
40
+ function resolveIncludes(obj, basePath, configRoot, loadIncludedYaml, visitedFiles = new Set()) {
41
+ if (Array.isArray(obj)) {
42
+ return obj.map((item) => resolveIncludes(item, basePath, configRoot, loadIncludedYaml, visitedFiles));
43
+ }
44
+ if (obj && typeof obj === 'object') {
45
+ const includePath = obj[INCLUDE];
46
+ if (includePath !== undefined) {
47
+ const filePath = path_1.default.resolve(basePath, includePath);
48
+ (0, utils_1.assertInsideConfigRoot)(includePath, filePath, configRoot);
49
+ if (visitedFiles.has(filePath)) {
50
+ throw new Error(`Circular include detected: ${filePath}`);
51
+ }
52
+ let stat;
53
+ try {
54
+ stat = fs_extra_1.default.statSync(filePath);
55
+ }
56
+ catch (err) {
57
+ if (err.code === 'ENOENT') {
58
+ throw new Error(`Include file not found: ${filePath}`);
59
+ }
60
+ throw err;
61
+ }
62
+ if (stat.isDirectory()) {
63
+ throw new Error(`Include path is a directory, expected a file: ${filePath}`);
64
+ }
65
+ // visitedFiles backtracks (add before, delete after), so one shared set is enough
66
+ visitedFiles.add(filePath);
67
+ const result = resolveIncludes(loadIncludedYaml(filePath), path_1.default.dirname(filePath), configRoot, loadIncludedYaml, visitedFiles);
68
+ visitedFiles.delete(filePath);
69
+ return result;
70
+ }
71
+ // Only rebuild plain objects; pass others (e.g. a Date from a YAML timestamp) through untouched
72
+ if (!(0, lodash_1.isPlainObject)(obj)) {
73
+ return obj;
74
+ }
75
+ const result = {};
76
+ for (const [key, value] of Object.entries(obj)) {
77
+ const resolved = resolveIncludes(value, basePath, configRoot, loadIncludedYaml, visitedFiles);
78
+ // Define `__proto__` as an own property so an included file can't pollute the prototype
79
+ if (key === '__proto__') {
80
+ Object.defineProperty(result, key, {
81
+ value: resolved,
82
+ enumerable: true,
83
+ writable: true,
84
+ configurable: true,
85
+ });
86
+ }
87
+ else {
88
+ result[key] = resolved;
89
+ }
90
+ }
91
+ return result;
92
+ }
93
+ return obj;
94
+ }
17
95
  class YAMLContext {
18
96
  constructor(config, mgmtClient) {
19
97
  this.configFile = config.AUTH0_INPUT_FILE;
@@ -61,9 +139,12 @@ class YAMLContext {
61
139
  try {
62
140
  const fPath = path_1.default.resolve(this.configFile);
63
141
  logger_1.default.debug(`Loading YAML from ${fPath}`);
64
- Object.assign(this.assets, js_yaml_1.default.load(opts.disableKeywordReplacement
65
- ? (0, tools_1.wrapArrayReplaceMarkersInQuotes)(fs_extra_1.default.readFileSync(fPath, 'utf8'), this.mappings)
66
- : (0, tools_1.keywordReplace)(fs_extra_1.default.readFileSync(fPath, 'utf8'), this.mappings)) || {});
142
+ const prepareContent = (content) => prepareYamlContent(content, this.mappings, opts.disableKeywordReplacement);
143
+ const loadIncludedYaml = (filePath) => parseYaml(prepareContent(fs_extra_1.default.readFileSync(filePath, 'utf8')));
144
+ const parsed = parseYaml(prepareContent(fs_extra_1.default.readFileSync(fPath, 'utf8')));
145
+ // Bound includes by the same config root loadFile() enforces
146
+ const configRoot = path_1.default.resolve(this.basePath);
147
+ Object.assign(this.assets, resolveIncludes(parsed, path_1.default.dirname(fPath), configRoot, loadIncludedYaml) || {});
67
148
  }
68
149
  catch (err) {
69
150
  logger_1.default.debug(err.stack);
@@ -165,6 +165,17 @@ export declare const schema: {
165
165
  suspiciousIpThrottling: {
166
166
  type: string;
167
167
  };
168
+ phoneProviderProtection: {
169
+ type: string;
170
+ properties: {
171
+ type: {
172
+ type: string;
173
+ enum: string[];
174
+ description: string;
175
+ };
176
+ };
177
+ required: string[];
178
+ };
168
179
  };
169
180
  additionalProperties: boolean;
170
181
  };
@@ -174,6 +185,7 @@ export type AttackProtection = {
174
185
  bruteForceProtection: Asset;
175
186
  captcha?: Asset | null;
176
187
  suspiciousIpThrottling: Asset;
188
+ phoneProviderProtection?: Asset | null;
177
189
  };
178
190
  export default class AttackProtectionHandler extends DefaultAPIHandler {
179
191
  existing: AttackProtection | null;
@@ -180,6 +180,17 @@ exports.schema = {
180
180
  suspiciousIpThrottling: {
181
181
  type: 'object',
182
182
  },
183
+ phoneProviderProtection: {
184
+ type: 'object',
185
+ properties: {
186
+ type: {
187
+ type: 'string',
188
+ enum: ['exponential', 'default'],
189
+ description: 'The backoff strategy applied to Send SMS during MFA enrollment. Gated by the sms_exponential_backoff feature flag; when the flag is disabled the API returns 403 and this setting is skipped on export.',
190
+ },
191
+ },
192
+ required: ['type'],
193
+ },
183
194
  },
184
195
  additionalProperties: false,
185
196
  };
@@ -227,6 +238,11 @@ class AttackProtectionHandler extends default_1.default {
227
238
  enabled: item.suspiciousIpThrottling.enabled,
228
239
  };
229
240
  }
241
+ if (item.phoneProviderProtection?.type) {
242
+ obj['phone-provider-protection'] = {
243
+ type: item.phoneProviderProtection.type,
244
+ };
245
+ }
230
246
  return obj;
231
247
  })();
232
248
  return super.objString(objectString);
@@ -242,6 +258,7 @@ class AttackProtectionHandler extends default_1.default {
242
258
  ]);
243
259
  let botDetection = null;
244
260
  let captcha = null;
261
+ let phoneProviderProtection = null;
245
262
  try {
246
263
  botDetection = await this.client.attackProtection.botDetection.get();
247
264
  }
@@ -264,6 +281,17 @@ class AttackProtectionHandler extends default_1.default {
264
281
  throw err;
265
282
  }
266
283
  }
284
+ try {
285
+ phoneProviderProtection = await this.client.attackProtection.phoneProviderProtection.get();
286
+ }
287
+ catch (err) {
288
+ if (err.statusCode === 403) {
289
+ logger_1.default.warn('Phone provider protection is not enabled for this tenant (sms_exponential_backoff feature flag). Skipping phone provider protection settings.');
290
+ }
291
+ else {
292
+ throw err;
293
+ }
294
+ }
267
295
  const attackProtection = {
268
296
  breachedPasswordDetection: breachedPasswordDetection,
269
297
  bruteForceProtection: bruteForceProtection,
@@ -275,6 +303,9 @@ class AttackProtectionHandler extends default_1.default {
275
303
  if (captcha) {
276
304
  attackProtection.captcha = captcha;
277
305
  }
306
+ if (phoneProviderProtection) {
307
+ attackProtection.phoneProviderProtection = phoneProviderProtection;
308
+ }
278
309
  this.existing = attackProtection;
279
310
  return this.existing;
280
311
  }
@@ -321,6 +352,22 @@ class AttackProtectionHandler extends default_1.default {
321
352
  if (attackProtection.suspiciousIpThrottling) {
322
353
  updates.push(this.client.attackProtection.suspiciousIpThrottling.update(attackProtection.suspiciousIpThrottling));
323
354
  }
355
+ // Phone provider protection uses PATCH (not update) and requires `type` on every request.
356
+ // Guard the 403 (sms_exponential_backoff flag off) per-promise so a flag-off tenant does
357
+ // not fail the whole attackProtection stage — mirrors the 403-skip in getType().
358
+ if (attackProtection.phoneProviderProtection?.type) {
359
+ updates.push(this.client.attackProtection.phoneProviderProtection
360
+ .patch({
361
+ type: attackProtection.phoneProviderProtection.type,
362
+ })
363
+ .catch((err) => {
364
+ if (err.statusCode === 403) {
365
+ logger_1.default.warn('Phone provider protection is not enabled for this tenant (sms_exponential_backoff feature flag). Skipping phone provider protection update.');
366
+ return;
367
+ }
368
+ throw err;
369
+ }));
370
+ }
324
371
  if (!updates.length) {
325
372
  return;
326
373
  }
@@ -354,7 +354,7 @@ exports.schema = {
354
354
  description: 'An ordered array of notification channels enabled for CIBA (Client-Initiated Backchannel Authentication) requests. Channels are evaluated in the order specified.',
355
355
  items: {
356
356
  type: 'string',
357
- enum: ['guardian-push', 'email'],
357
+ enum: ['guardian-push', 'email', 'my-account'],
358
358
  },
359
359
  },
360
360
  skip_non_verifiable_callback_uri_confirmation_prompt: {
@@ -230,6 +230,7 @@ export declare const processConnectionEnabledClients: (auth0Client: Auth0APIClie
230
230
  export default class ConnectionsHandler extends DefaultAPIHandler {
231
231
  existing: Connection[] | null;
232
232
  scimHandler: ScimHandler;
233
+ private enabledClientsEnrichmentFilter;
233
234
  constructor(config: DefaultAPIHandler);
234
235
  objString(connection: any): string;
235
236
  getFormattedOptions(connection: any, clients: any): {
@@ -270,6 +271,7 @@ export default class ConnectionsHandler extends DefaultAPIHandler {
270
271
  * @param changes - Object containing arrays of connections to create, update, and resolve conflicts for
271
272
  */
272
273
  processConnectionDirectoryProvisioning(changes: CalculatedChanges): Promise<void>;
274
+ private setEnabledClientsEnrichmentFilter;
273
275
  getType(): Promise<Asset[] | null>;
274
276
  calcChanges(assets: Assets): Promise<CalculatedChanges>;
275
277
  dryRunChanges(assets: Assets): Promise<CalculatedChanges>;
@@ -372,6 +372,10 @@ class ConnectionsHandler extends default_1.default {
372
372
  create: async (bodyParams) => this.scimHandler.createOverride(bodyParams),
373
373
  },
374
374
  });
375
+ // On import, limits getType()'s enabled_clients enrichment to connections in
376
+ // the local config, skipping an API call per remote-only connection on large
377
+ // tenants. Null on export, where every connection is enriched.
378
+ this.enabledClientsEnrichmentFilter = null;
375
379
  // @ts-ignore
376
380
  this.scimHandler = new scimHandler_1.default(this.config, this.client.connections, this.client.pool);
377
381
  }
@@ -621,9 +625,19 @@ class ConnectionsHandler extends default_1.default {
621
625
  })
622
626
  .promise();
623
627
  }
628
+ // Scopes getType()'s enabled_clients enrichment to the local config's
629
+ // connection names. Called on import; export leaves it unset (enrich all).
630
+ setEnabledClientsEnrichmentFilter(connections) {
631
+ this.enabledClientsEnrichmentFilter = new Set(connections
632
+ .map((connection) => connection.name)
633
+ .filter((name) => typeof name === 'string'));
634
+ }
624
635
  async getType() {
625
636
  if (this.existing)
626
637
  return this.existing;
638
+ // Consume the import filter and reset it so it can't leak into a later call.
639
+ const enrichmentFilter = this.enabledClientsEnrichmentFilter;
640
+ this.enabledClientsEnrichmentFilter = null;
627
641
  const [connections, directoryProvisioningConfigs] = await Promise.all([
628
642
  (0, client_1.paginate)(this.client.connections.list, {
629
643
  checkpoint: true,
@@ -640,7 +654,11 @@ class ConnectionsHandler extends default_1.default {
640
654
  this.existing = filteredConnections;
641
655
  if (this.existing === null)
642
656
  return [];
643
- const connectionTasks = filteredConnections.map((con, index) => ({ con, index }));
657
+ // Import: enrich only local-config connections. Export (null): enrich all.
658
+ const shouldEnrich = (con) => enrichmentFilter === null || (con.name != null && enrichmentFilter.has(con.name));
659
+ const connectionTasks = filteredConnections
660
+ .map((con, index) => ({ con, index }))
661
+ .filter(({ con }) => shouldEnrich(con));
644
662
  const connectionsWithEnabledClients = await this.client.pool
645
663
  .addEachTask({
646
664
  data: connectionTasks,
@@ -676,9 +694,9 @@ class ConnectionsHandler extends default_1.default {
676
694
  },
677
695
  })
678
696
  .promise();
679
- this.existing = connectionsWithEnabledClients
680
- .sort((a, b) => a.index - b.index)
681
- .map(({ connection }) => connection);
697
+ // Merge enriched connections back by index; skipped ones pass through.
698
+ const enrichedByIndex = new Map(connectionsWithEnabledClients.map(({ index, connection }) => [index, connection]));
699
+ this.existing = filteredConnections.map((con, index) => enrichedByIndex.get(index) ?? con);
682
700
  // Apply `scim_configuration` to all the relevant `SCIM` connections. This method mutates `this.existing`.
683
701
  await this.scimHandler.applyScimConfiguration(this.existing);
684
702
  return this.existing;
@@ -693,6 +711,8 @@ class ConnectionsHandler extends default_1.default {
693
711
  update: [],
694
712
  conflicts: [],
695
713
  };
714
+ // Scope getType() enrichment to the local config (see getType()).
715
+ this.setEnabledClientsEnrichmentFilter(connections);
696
716
  // Convert enabled_clients by name to the id
697
717
  const clients = await (0, client_1.paginate)(this.client.clients.list, {
698
718
  paginate: true,
@@ -730,6 +750,8 @@ class ConnectionsHandler extends default_1.default {
730
750
  conflicts: [],
731
751
  };
732
752
  }
753
+ // Scope getType() enrichment to the local config (see getType()).
754
+ this.setEnabledClientsEnrichmentFilter(connections);
733
755
  const clients = await (0, client_1.paginate)(this.client.clients.list, {
734
756
  paginate: true,
735
757
  include_totals: true,
@@ -47,7 +47,18 @@ export declare const schema: {
47
47
  type: string;
48
48
  };
49
49
  customText: {
50
+ $id: string;
50
51
  type: string;
52
+ definitions: {
53
+ promptType: {
54
+ type: string;
55
+ properties: {};
56
+ };
57
+ language: {
58
+ type: string;
59
+ properties: {};
60
+ };
61
+ };
51
62
  properties: {};
52
63
  };
53
64
  partials: {
@@ -87,6 +98,12 @@ export default class PromptsHandler extends DefaultHandler {
87
98
  constructor(options: DefaultHandler);
88
99
  objString({ customText, screenRenderers }: Prompts): string;
89
100
  getType(): Promise<Prompts | null>;
101
+ /**
102
+ * Narrows the tenant's enabled languages to those listed in AUTH0_EXPORT_CUSTOM_TEXT_LANGUAGES.
103
+ * Custom text is fetched with one rate-limited request per language and prompt type, so
104
+ * limiting languages is the only way to shorten this step on tenants with many languages.
105
+ */
106
+ getCustomTextLanguages<T extends string>(enabledLanguages: T[]): T[];
90
107
  getCustomTextSettings(): Promise<AllPromptsByLanguage>;
91
108
  /**
92
109
  * Error handler wrapper.
@@ -199,25 +199,35 @@ exports.schema = {
199
199
  identifier_first: {
200
200
  type: 'boolean',
201
201
  },
202
+ // Every language/prompt shares the same shape, so the per-prompt and per-language schemas are
203
+ // defined once and referenced. Inlining them multiplies the schema by
204
+ // languages x promptTypes x screenTypes (~184k subschemas), which costs ~1 GB and ~2.5s of
205
+ // Ajv compile time on every `deploy`. The `$id` scopes the `#/definitions/...` refs to this
206
+ // subschema so they resolve wherever it is embedded.
202
207
  customText: {
208
+ $id: 'prompts-custom-text',
203
209
  type: 'object',
204
- properties: types_1.languages.reduce((acc, language) => ({
205
- ...acc,
206
- [language]: {
210
+ definitions: {
211
+ promptType: {
207
212
  type: 'object',
208
- properties: promptTypes.reduce((promptAcc, promptType) => ({
209
- ...promptAcc,
210
- [promptType]: {
213
+ properties: screenTypes.reduce((screenAcc, screenType) => ({
214
+ ...screenAcc,
215
+ [screenType]: {
211
216
  type: 'object',
212
- properties: screenTypes.reduce((screenAcc, screenType) => ({
213
- ...screenAcc,
214
- [screenType]: {
215
- type: 'object',
216
- },
217
- }), {}),
218
217
  },
219
218
  }), {}),
220
219
  },
220
+ language: {
221
+ type: 'object',
222
+ properties: promptTypes.reduce((promptAcc, promptType) => ({
223
+ ...promptAcc,
224
+ [promptType]: { $ref: '#/definitions/promptType' },
225
+ }), {}),
226
+ },
227
+ },
228
+ properties: types_1.languages.reduce((acc, language) => ({
229
+ ...acc,
230
+ [language]: { $ref: '#/definitions/language' },
221
231
  }), {}),
222
232
  },
223
233
  partials: {
@@ -306,12 +316,35 @@ class PromptsHandler extends default_1.default {
306
316
  }
307
317
  return prompts;
308
318
  }
319
+ /**
320
+ * Narrows the tenant's enabled languages to those listed in AUTH0_EXPORT_CUSTOM_TEXT_LANGUAGES.
321
+ * Custom text is fetched with one rate-limited request per language and prompt type, so
322
+ * limiting languages is the only way to shorten this step on tenants with many languages.
323
+ */
324
+ getCustomTextLanguages(enabledLanguages) {
325
+ const configuredLanguages = this.config('AUTH0_EXPORT_CUSTOM_TEXT_LANGUAGES');
326
+ if (configuredLanguages === undefined)
327
+ return enabledLanguages;
328
+ if (!Array.isArray(configuredLanguages) ||
329
+ !configuredLanguages.every((language) => typeof language === 'string')) {
330
+ throw new Error('AUTH0_EXPORT_CUSTOM_TEXT_LANGUAGES must be an array of language codes, e.g. ["en", "fr"].');
331
+ }
332
+ const notEnabledLanguages = configuredLanguages.filter((language) => !enabledLanguages.includes(language));
333
+ if (notEnabledLanguages.length > 0) {
334
+ logger_1.default.warn(`Skipping custom text for languages listed in AUTH0_EXPORT_CUSTOM_TEXT_LANGUAGES that are not enabled on the tenant: ${notEnabledLanguages.join(', ')}`);
335
+ }
336
+ const languages = enabledLanguages.filter((language) => configuredLanguages.includes(language));
337
+ logger_1.default.info(`Retrieving prompts custom text only for languages in AUTH0_EXPORT_CUSTOM_TEXT_LANGUAGES: ${languages.join(', ') || '(none)'}`);
338
+ return languages;
339
+ }
309
340
  async getCustomTextSettings() {
310
- const supportedLanguages = await this.client.tenants.settings.get().then((res) => {
341
+ const enabledLanguages = await this.client.tenants.settings.get().then((res) => {
311
342
  if (res.enabled_locales === undefined)
312
343
  return []; // In rare cases, private cloud tenants may not have `enabled_locales` defined
313
344
  return res.enabled_locales;
314
345
  });
346
+ const supportedLanguages = this.getCustomTextLanguages(enabledLanguages);
347
+ logger_1.default.debug(`Fetching prompts custom text for ${supportedLanguages.length} language(s) x ${promptTypes.length} prompt type(s) (${supportedLanguages.length * promptTypes.length} requests). This endpoint is rate-limited by Auth0, so this may take a while on tenants with many languages enabled; set AUTH0_EXPORT_CUSTOM_TEXT_LANGUAGES to limit which languages are fetched.`);
315
348
  return this.client.pool
316
349
  .addEachTask({
317
350
  data: supportedLanguages
@@ -137,6 +137,10 @@ export declare const schema: {
137
137
  description: string;
138
138
  readOnly: boolean;
139
139
  };
140
+ require_consent_non_repudiation: {
141
+ type: string;
142
+ description: string;
143
+ };
140
144
  };
141
145
  required: string[];
142
146
  };
@@ -124,6 +124,10 @@ exports.schema = {
124
124
  description: 'The client ID of the client that this resource server is linked to (readonly)',
125
125
  readOnly: true,
126
126
  },
127
+ require_consent_non_repudiation: {
128
+ type: 'boolean',
129
+ description: 'When true, the authorization server rejects consent decisions that do not include a valid signature and kid. Requires the my_account_consents entitlement.',
130
+ },
127
131
  },
128
132
  required: ['name', 'identifier'],
129
133
  },
@@ -164,6 +168,7 @@ class ResourceServersHandler extends default_1.default {
164
168
  'id',
165
169
  'is_system',
166
170
  'authorization_policy',
171
+ 'require_consent_non_repudiation',
167
172
  ];
168
173
  const sanitized = {};
169
174
  allowedKeys.forEach((key) => {
@@ -208,14 +213,19 @@ class ResourceServersHandler extends default_1.default {
208
213
  });
209
214
  }
210
215
  async updateResourceServer(id, update) {
211
- // Exclude name from update as it cannot be modified for system resource servers like Auth0 My Account API
212
- if (update.is_system === true || update.name === 'Auth0 My Account API') {
216
+ // Exclude name from update as it cannot be modified for system resource servers like the
217
+ // Auth0 My Account API or the Auth0 My Organization API. `is_system` is listed in
218
+ // `stripUpdateFields`, so it has already been removed from `update` by the time this runs -
219
+ // read it off the existing resource server instead, which `getType()` retains it on.
220
+ const existing = this.existing?.find((resourceServer) => resourceServer.id === id);
221
+ if (existing?.is_system === true) {
213
222
  const updateFields = {
214
223
  token_lifetime: update.token_lifetime,
215
224
  proof_of_possession: update.proof_of_possession,
216
225
  skip_consent_for_verifiable_first_party_clients: update.skip_consent_for_verifiable_first_party_clients,
217
226
  subject_type_authorization: update.subject_type_authorization,
218
227
  authorization_policy: update.authorization_policy,
228
+ require_consent_non_repudiation: update.require_consent_non_repudiation,
219
229
  };
220
230
  return this.client.resourceServers.update(id, updateFields);
221
231
  }
@@ -132,22 +132,30 @@ class RolesHandler extends default_1.default {
132
132
  }
133
133
  }
134
134
  async updateRole(data, roles) {
135
- const existingRole = await roles.find((roleDataForUpdate) => roleDataForUpdate.name === data.name);
135
+ // Match on id first so a renamed role (matched by id, new name) still resolves to its
136
+ // existing permissions; fall back to name for configs imported without ids.
137
+ const existingRole = roles.find((r) => r.id === data.id) || roles.find((r) => r.name === data.name);
136
138
  const params = { id: data.id };
137
- const newPermissions = data.permissions;
139
+ const newPermissions = data.permissions || [];
138
140
  delete data.permissions;
139
141
  delete data.id;
140
142
  // deploy-cli only manages tenant-level roles, so `type` is stripped on write; this
141
143
  // lets files that already carry it (from a prior export) be imported cleanly.
142
144
  delete data.type;
143
- await this.client.roles.update(params.id, data);
144
- if (typeof existingRole.permissions !== 'undefined' && existingRole.permissions.length > 0) {
145
- await this.client.roles.permissions.delete(params.id, {
146
- permissions: existingRole.permissions,
147
- });
145
+ const retryConfig = this.getRetryConfig();
146
+ await (0, default_1.retryWithExponentialBackoff)(() => this.client.roles.update(params.id, data), retryConfig);
147
+ // Only touch permissions that actually changed, and add before removing so an
148
+ // interrupted run can never leave a role with fewer permissions than it started with.
149
+ const existingPermissions = existingRole.permissions || [];
150
+ const samePermission = (a, b) => a.permission_name === b.permission_name &&
151
+ a.resource_server_identifier === b.resource_server_identifier;
152
+ const permissionsToAdd = newPermissions.filter((p) => !existingPermissions.some((e) => samePermission(e, p)));
153
+ const permissionsToRemove = existingPermissions.filter((e) => !newPermissions.some((p) => samePermission(e, p)));
154
+ if (permissionsToAdd.length > 0) {
155
+ await (0, default_1.retryWithExponentialBackoff)(() => this.client.roles.permissions.add(params.id, { permissions: permissionsToAdd }), retryConfig);
148
156
  }
149
- if (typeof newPermissions !== 'undefined' && newPermissions.length > 0) {
150
- await this.client.roles.permissions.add(params.id, { permissions: newPermissions });
157
+ if (permissionsToRemove.length > 0) {
158
+ await (0, default_1.retryWithExponentialBackoff)(() => this.client.roles.permissions.delete(params.id, { permissions: permissionsToRemove }), retryConfig);
151
159
  }
152
160
  return params;
153
161
  }
@@ -39,6 +39,10 @@ class ScimHandler {
39
39
  * @param connections
40
40
  */
41
41
  async createIdMap(connections) {
42
+ // No `read:scim_config` scope means nothing to fetch. Return before clearing
43
+ // to keep any existing map and skip a no-op pool task per connection.
44
+ if (!this.scimScopes.read)
45
+ return;
42
46
  this.idMap.clear();
43
47
  const logMsg = 'Reviewing connections for SCIM support. This may take a while...';
44
48
  if (nconf_1.default.get('AUTH0_DRY_RUN')) {
@@ -47,14 +51,15 @@ class ScimHandler {
47
51
  else {
48
52
  logger_1.default.info(logMsg);
49
53
  }
54
+ // Only SCIM-capable strategies can carry a SCIM configuration; filtering here
55
+ // keeps non-SCIM connections out of the throttled pool.
56
+ const scimConnections = (connections || []).filter((connection) => this.isScimStrategy(connection.strategy));
50
57
  await this.poolClient
51
58
  .addEachTask({
52
- data: connections || [],
59
+ data: scimConnections,
53
60
  generator: (connection) => {
54
61
  if (!this.scimScopes.read)
55
62
  return Promise.resolve(null);
56
- if (!this.isScimStrategy(connection.strategy))
57
- return Promise.resolve(null);
58
63
  this.idMap.set(connection.id, { strategy: connection.strategy });
59
64
  return this.getScimConfiguration(connection.id)
60
65
  .then((response) => {
@@ -35,6 +35,7 @@ declare const constants: {
35
35
  EMAIL_MFA_OOB_CODE: string;
36
36
  EMAIL_USER_INVITATION: string;
37
37
  EMAIL_ASYNC_APPROVAL: string;
38
+ EMAIL_AUTH_BY_CODE: string;
38
39
  GUARDIAN_DIRECTORY: string;
39
40
  GUARDIAN_FACTORS_DIRECTORY: string;
40
41
  GUARDIAN_PROVIDERS_DIRECTORY: string;
@@ -20,6 +20,7 @@ const EMAIL_PASSWORD_RESET = 'password_reset';
20
20
  const EMAIL_MFA_OOB_CODE = 'mfa_oob_code';
21
21
  const EMAIL_USER_INVITATION = 'user_invitation';
22
22
  const EMAIL_ASYNC_APPROVAL = 'async_approval';
23
+ const EMAIL_AUTH_BY_CODE = 'auth_email_by_code';
23
24
  const UNIVERSAL_LOGIN_TEMPLATE = 'universal_login';
24
25
  const OBFUSCATED_SECRET_VALUE = '_VALUE_NOT_SHOWN_';
25
26
  const constants = {
@@ -56,6 +57,7 @@ const constants = {
56
57
  'password_reset',
57
58
  'user_invitation',
58
59
  'async_approval',
60
+ 'auth_email_by_code',
59
61
  ],
60
62
  ACTIONS_TRIGGERS: [
61
63
  'post-login',
@@ -84,6 +86,7 @@ const constants = {
84
86
  EMAIL_MFA_OOB_CODE,
85
87
  EMAIL_USER_INVITATION,
86
88
  EMAIL_ASYNC_APPROVAL,
89
+ EMAIL_AUTH_BY_CODE,
87
90
  GUARDIAN_DIRECTORY: 'guardian',
88
91
  GUARDIAN_FACTORS_DIRECTORY: 'factors',
89
92
  GUARDIAN_PROVIDERS_DIRECTORY: 'providers',
@@ -181,6 +184,8 @@ const constants = {
181
184
  `${EMAIL_USER_INVITATION}.html`,
182
185
  `${EMAIL_ASYNC_APPROVAL}.json`,
183
186
  `${EMAIL_ASYNC_APPROVAL}.html`,
187
+ `${EMAIL_AUTH_BY_CODE}.json`,
188
+ `${EMAIL_AUTH_BY_CODE}.html`,
184
189
  ],
185
190
  SUPPORTED_BRANDING_TEMPLATES: [UNIVERSAL_LOGIN_TEMPLATE],
186
191
  LOG_STREAMS_DIRECTORY: 'log-streams',
package/lib/types.d.ts CHANGED
@@ -77,6 +77,7 @@ export type Config = {
77
77
  AUTH0_EXPORT_IDENTIFIERS?: boolean;
78
78
  AUTH0_EXPORT_ORDERED?: boolean;
79
79
  AUTH0_EXPORT_SECRETS?: boolean;
80
+ AUTH0_EXPORT_CUSTOM_TEXT_LANGUAGES?: string[];
80
81
  AUTH0_CONNECTIONS_DIRECTORY?: string;
81
82
  AUTH0_DRY_RUN?: boolean | 'preview';
82
83
  AUTH0_DRY_RUN_INTERACTIVE?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auth0-deploy-cli",
3
- "version": "9.0.0",
3
+ "version": "9.1.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": "^7.2.0",
38
+ "auth0": "^7.3.0",
39
39
  "chalk": "5.6.2",
40
40
  "dot-prop": "^5.3.0",
41
41
  "fs-extra": "^10.1.0",