az2aws 1.6.2 → 1.7.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/.gitattributes ADDED
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.0](https://github.com/kuma0128/az2aws/compare/v1.6.2...v1.7.0) (2026-04-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * add credential_process output mode ([#95](https://github.com/kuma0128/az2aws/issues/95)) ([2a3e66f](https://github.com/kuma0128/az2aws/commit/2a3e66fcd19fd461dd2f84fc32caa383fb56990c))
9
+ * add macOS CI coverage and separate E2E job ([#183](https://github.com/kuma0128/az2aws/issues/183)) ([cf617f8](https://github.com/kuma0128/az2aws/commit/cf617f899b5d2ba88650f7ffd26c3195dc32c695))
10
+ * Use Windows app-data conventions for update notifier cache ([#180](https://github.com/kuma0128/az2aws/issues/180)) ([8383ae4](https://github.com/kuma0128/az2aws/commit/8383ae46a468630ce531cc9df0dd7d64f115e49c))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * --no-prompt on account selection screen ([#173](https://github.com/kuma0128/az2aws/issues/173)) ([07c833f](https://github.com/kuma0128/az2aws/commit/07c833ff8a4553b8c32ed7841b3544776c41f907))
16
+ * clear password input before typing to prevent appending ([#171](https://github.com/kuma0128/az2aws/issues/171)) ([dfb0d65](https://github.com/kuma0128/az2aws/commit/dfb0d65c85fdb3a38bbc2d6ccdbd492f18e97c25))
17
+ * enforce whole number validation for session duration hours ([#167](https://github.com/kuma0128/az2aws/issues/167)) ([0bc7fb4](https://github.com/kuma0128/az2aws/commit/0bc7fb4f187dad422f08a42ece57a4b04138c51d))
18
+ * PATH handling when installing Chrome in CI workflow ([#184](https://github.com/kuma0128/az2aws/issues/184)) ([f6fd008](https://github.com/kuma0128/az2aws/commit/f6fd008a65f7c722b78ae65121e1e20f421dc232))
19
+ * support custom AWS config parent directories ([#172](https://github.com/kuma0128/az2aws/issues/172)) ([19753dd](https://github.com/kuma0128/az2aws/commit/19753dd769f31a72b4e0bdb84acf9638c1cd67f7))
20
+
3
21
  ## [1.6.2](https://github.com/kuma0128/az2aws/compare/v1.6.1...v1.6.2) (2026-04-10)
4
22
 
5
23
 
package/CONTRIBUTING.md CHANGED
@@ -90,11 +90,31 @@ pnpm build && node ./lib/index.js
90
90
  | `pnpm build` | Build for production |
91
91
  | `pnpm test` | Run unit tests |
92
92
  | `pnpm test:coverage` | Run unit tests with coverage |
93
+ | `pnpm test:e2e` | Run the live Azure→AWS browser smoke test |
93
94
  | `pnpm lint` | Run ESLint and formatting checks |
94
95
  | `pnpm eslint` | Run ESLint |
95
96
  | `pnpm prettier:check` | Check code formatting |
96
97
  | `pnpm prettier:write` | Auto-fix code formatting |
97
98
 
99
+ ### E2E Smoke Test
100
+
101
+ `pnpm test:e2e` launches a real Puppeteer/Chrome session, signs in through
102
+ Azure AD, and verifies that az2aws can retrieve AWS credentials via
103
+ `credential_process` without persisting them to the shared credentials file.
104
+
105
+ Copy `.env.example` to `.env` and fill in the values. See
106
+ [`vitest.e2e.config.ts`](vitest.e2e.config.ts) for the Vitest settings used by
107
+ this suite.
108
+
109
+ To troubleshoot a failing run, rerun with `AZ2AWS_E2E_MODE=debug` (visible
110
+ browser, auto-fill) or `AZ2AWS_E2E_MODE=gui` (fully manual).
111
+
112
+ > **Note:** Passkey-first Microsoft accounts are not supported — use a dedicated
113
+ > account with standard username/password/MFA.
114
+
115
+ CI only runs this test on `push` to `main` and `workflow_dispatch`; PR
116
+ validation does not depend on repository secrets.
117
+
98
118
  ## Development Workflow
99
119
 
100
120
  1. Create a new branch from `main`:
package/README.md CHANGED
@@ -67,9 +67,13 @@ You must install [puppeteer dependencies](https://github.com/GoogleChrome/puppet
67
67
 
68
68
  #### Windows Notes
69
69
 
70
- If you get a missing Chrome/Chromium error, install the puppeteer dependency manually:
70
+ If you get a missing Chrome/Chromium error, reinstall the Puppeteer browser from the installed `az2aws` package directory:
71
71
 
72
- node <node_modules_dir>/az2aws/node_modules/puppeteer/install.js
72
+ node <npm_global_node_modules>\az2aws\node_modules\puppeteer\install.mjs
73
+
74
+ For an npm global install, replace `<npm_global_node_modules>` with the output of `npm root -g`.
75
+ If you installed az2aws with pnpm or another package manager, locate `puppeteer/install.mjs`
76
+ under the installed `az2aws` package directory and run it with `node`.
73
77
 
74
78
  ### Docker
75
79
 
@@ -112,6 +116,7 @@ https://snapcraft.io/az2aws
112
116
  | `--no-disable-extensions` | Keep browser extensions enabled |
113
117
  | `--disable-gpu` | Disable GPU acceleration |
114
118
  | `--incognito` | Open the login flow in an incognito browser context |
119
+ | `--credential-process` | Output credentials for AWS CLI credential_process |
115
120
  | `--version (-v)` | Show version number |
116
121
 
117
122
  ## Usage
@@ -148,6 +153,29 @@ Enable "Stay logged in" during configuration to use `--no-prompt` without storin
148
153
  helps avoid reusing an existing browser session, and it overrides any saved
149
154
  "Stay logged in" browser state for that run.
150
155
 
156
+ #### AWS CLI credential_process
157
+
158
+ Configure the profile first so it has the defaults needed for non-interactive
159
+ login, then point AWS CLI at `az2aws`:
160
+
161
+ [profile myprofile]
162
+ credential_process = az2aws --profile myprofile --credential-process
163
+
164
+ `--credential-process` uses the same non-interactive defaults as `--no-prompt`,
165
+ so make sure the profile already has the role and other required values set.
166
+ Standard output is reserved for the AWS CLI JSON payload, while human-readable
167
+ status messages are written to stderr.
168
+
169
+ Example stdout payload:
170
+
171
+ {
172
+ "Version": 1,
173
+ "AccessKeyId": "...",
174
+ "SecretAccessKey": "...",
175
+ "SessionToken": "...",
176
+ "Expiration": "2026-01-01T00:00:00.000Z"
177
+ }
178
+
151
179
  #### Environment Variables
152
180
 
153
181
  You can set defaults via environment variables (use with `--no-prompt`):
@@ -192,10 +220,15 @@ Example:
192
220
  az2aws # Default profile
193
221
  az2aws --profile foo # Named profile
194
222
  az2aws --mode gui # Use browser UI (more reliable)
223
+ az2aws --mode debug # Show the browser while az2aws still drives the flow
195
224
  az2aws --mode gui --incognito # Open a fresh incognito login window
196
225
 
197
226
  You'll be prompted for username, password, and MFA if required. After login, use AWS CLI/SDKs as usual.
198
227
 
228
+ `--mode gui` is fully manual and waits for you to complete the browser flow
229
+ yourself. If you want the browser to stay visible while az2aws still auto-fills
230
+ the login steps, use `--mode debug`.
231
+
199
232
  **Tips:**
200
233
  - Set `AWS_PROFILE` env var instead of using `--profile`
201
234
  - Use `--mode gui --disable-gpu` on VMs or if rendering fails
@@ -217,6 +250,13 @@ If you see device compliance errors (e.g., "Device UnSecured Or Non-Compliant"),
217
250
  Try:
218
251
  `--mode gui` and use your system Chrome via `BROWSER_CHROME_BIN`.
219
252
 
253
+ If your Microsoft account requires a saved passkey prompt before the username
254
+ or password page appears, that flow is unsupported in `az2aws --mode cli`.
255
+ The prompt is rendered by the browser/OS passkey UI instead of the page DOM,
256
+ so az2aws cannot dismiss it automatically. Use `--mode gui` and handle it
257
+ manually, or use an account that can continue with the standard page-based
258
+ username/password/MFA flow.
259
+
220
260
  If you see "Unable to recognize page state!", Azure's login pages may have
221
261
  changed. Try:
222
262
 
package/lib/awsConfig.js CHANGED
@@ -9,9 +9,79 @@ const debug_1 = __importDefault(require("debug"));
9
9
  const paths_1 = require("./paths");
10
10
  const promises_1 = require("node:fs/promises");
11
11
  const fs_1 = __importDefault(require("fs"));
12
+ const node_crypto_1 = __importDefault(require("node:crypto"));
13
+ const path_1 = __importDefault(require("path"));
12
14
  const util_1 = __importDefault(require("util"));
13
15
  const debug = (0, debug_1.default)("az2aws");
14
16
  const writeFile = util_1.default.promisify(fs_1.default.writeFile);
17
+ const awsDirMode = 0o700;
18
+ const awsFileMode = 0o600;
19
+ const ignoredChmodErrorCodes = new Set([
20
+ "EACCES",
21
+ "EINVAL",
22
+ "ENOSYS",
23
+ "ENOTSUP",
24
+ "EPERM",
25
+ "EROFS",
26
+ ]);
27
+ async function hardenPathPermissions(fsPath, mode) {
28
+ if (process.platform === "win32") {
29
+ return;
30
+ }
31
+ try {
32
+ await (0, promises_1.chmod)(fsPath, mode);
33
+ }
34
+ catch (error) {
35
+ const code = error.code;
36
+ if (typeof code === "string" && ignoredChmodErrorCodes.has(code)) {
37
+ debug(`Skipping permission hardening due to ${code}`);
38
+ return;
39
+ }
40
+ throw error;
41
+ }
42
+ }
43
+ async function hardenCreatedDirectories(createdDir, targetDir) {
44
+ const resolvedCreatedDir = path_1.default.resolve(createdDir);
45
+ const resolvedTargetDir = path_1.default.resolve(targetDir);
46
+ const relativeTargetDir = path_1.default.relative(resolvedCreatedDir, resolvedTargetDir);
47
+ if (relativeTargetDir === ".." ||
48
+ relativeTargetDir.startsWith(`..${path_1.default.sep}`) ||
49
+ path_1.default.isAbsolute(relativeTargetDir)) {
50
+ debug("Skipping permission hardening because the target directory is not within the created directory.");
51
+ return;
52
+ }
53
+ let currentDir = resolvedCreatedDir;
54
+ await hardenPathPermissions(currentDir, awsDirMode);
55
+ if (!relativeTargetDir) {
56
+ return;
57
+ }
58
+ for (const segment of relativeTargetDir.split(path_1.default.sep)) {
59
+ if (!segment || segment === ".") {
60
+ continue;
61
+ }
62
+ currentDir = path_1.default.join(currentDir, segment);
63
+ await hardenPathPermissions(currentDir, awsDirMode);
64
+ }
65
+ }
66
+ async function atomicWriteTextFile(targetPath, text) {
67
+ const targetDir = path_1.default.dirname(targetPath);
68
+ const tempPath = targetDir === "."
69
+ ? `.${path_1.default.basename(targetPath)}.${node_crypto_1.default.randomUUID()}.tmp`
70
+ : path_1.default.join(targetDir, `.${path_1.default.basename(targetPath)}.${node_crypto_1.default.randomUUID()}.tmp`);
71
+ let shouldCleanupTempPath = false;
72
+ try {
73
+ await writeFile(tempPath, text);
74
+ shouldCleanupTempPath = true;
75
+ await hardenPathPermissions(tempPath, awsFileMode);
76
+ await (0, promises_1.rename)(tempPath, targetPath);
77
+ shouldCleanupTempPath = false;
78
+ }
79
+ finally {
80
+ if (shouldCleanupTempPath) {
81
+ await (0, promises_1.rm)(tempPath, { force: true }).catch(() => undefined);
82
+ }
83
+ }
84
+ }
15
85
  // Autorefresh credential time limit in milliseconds
16
86
  const refreshLimitInMs = 11 * 60 * 1000;
17
87
  exports.awsConfig = {
@@ -71,11 +141,12 @@ exports.awsConfig = {
71
141
  return profiles;
72
142
  },
73
143
  async _loadAsync(type) {
74
- if (!paths_1.paths[type])
144
+ const targetPath = paths_1.paths[type];
145
+ if (!targetPath)
75
146
  throw new Error(`Unknown config type: '${type}'`);
76
147
  return new Promise((resolve, reject) => {
77
- debug(`Loading '${type}' file at '${paths_1.paths[type]}'`);
78
- fs_1.default.readFile(paths_1.paths[type], "utf8", (err, data) => {
148
+ debug(`Loading '${type}' file`);
149
+ fs_1.default.readFile(targetPath, "utf8", (err, data) => {
79
150
  if (err) {
80
151
  if (err.code === "ENOENT") {
81
152
  debug(`File not found. Returning undefined.`);
@@ -86,23 +157,45 @@ exports.awsConfig = {
86
157
  }
87
158
  }
88
159
  debug("Parsing data");
89
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
160
  const parsedIni = ini_1.default.parse(data);
91
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
92
161
  return resolve(parsedIni);
93
162
  });
94
163
  });
95
164
  },
96
165
  async _saveAsync(type, data) {
97
- if (!paths_1.paths[type])
166
+ const targetPath = paths_1.paths[type];
167
+ if (!targetPath)
98
168
  throw new Error(`Unknown config type: '${type}'`);
99
169
  if (!data)
100
170
  throw new Error(`You must provide data for saving.`);
101
171
  debug(`Stringifying ${type} INI data`);
102
172
  const text = ini_1.default.stringify(data);
103
- debug(`Creating AWS config directory '${paths_1.paths.awsDir}' if not exists.`);
104
- await (0, promises_1.mkdir)(paths_1.paths.awsDir, { recursive: true });
105
- debug(`Writing '${type}' INI to file '${paths_1.paths[type]}'`);
106
- await writeFile(paths_1.paths[type], text);
173
+ const targetDir = path_1.default.dirname(targetPath);
174
+ const isDefaultAwsDir = path_1.default.resolve(targetDir) === path_1.default.resolve(paths_1.paths.awsDir);
175
+ if (targetDir !== ".") {
176
+ debug(`Creating target directory for '${type}' if it does not exist.`);
177
+ const createdDir = await (0, promises_1.mkdir)(targetDir, {
178
+ recursive: true,
179
+ mode: awsDirMode,
180
+ });
181
+ if (isDefaultAwsDir) {
182
+ await hardenPathPermissions(targetDir, awsDirMode);
183
+ }
184
+ else if (createdDir) {
185
+ await hardenCreatedDirectories(createdDir, targetDir);
186
+ }
187
+ else {
188
+ debug("Skipping directory permission hardening for existing custom directory.");
189
+ }
190
+ }
191
+ else {
192
+ debug(`Skipping target directory creation for '${type}' because it uses the current working directory.`);
193
+ }
194
+ debug(`Writing '${type}' INI to file atomically`);
195
+ await atomicWriteTextFile(targetPath, text);
196
+ // Defensive: atomicWriteTextFile already sets permissions on the temp file
197
+ // before rename, but we re-apply here in case rename semantics differ across
198
+ // platforms or file-systems.
199
+ await hardenPathPermissions(targetPath, awsFileMode);
107
200
  },
108
201
  };
@@ -5,8 +5,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.configureProfileAsync = configureProfileAsync;
7
7
  const inquirer_1 = __importDefault(require("inquirer"));
8
+ const CLIError_1 = require("./CLIError");
8
9
  const awsConfig_1 = require("./awsConfig");
10
+ const sessionDuration_1 = require("./sessionDuration");
9
11
  async function configureProfileAsync(profileName) {
12
+ var _a;
10
13
  console.log(`Configuring profile '${profileName}'`);
11
14
  const profile = await awsConfig_1.awsConfig.getProfileConfigAsync(profileName);
12
15
  const questions = [
@@ -54,13 +57,8 @@ async function configureProfileAsync(profileName) {
54
57
  type: "input",
55
58
  name: "defaultDurationHours",
56
59
  message: "Default Session Duration Hours (up to 12):",
57
- default: (profile && profile.azure_default_duration_hours) || 1,
58
- validate: (input) => {
59
- const num = Number(input);
60
- if (num > 0 && num <= 12)
61
- return true;
62
- return "Duration hours must be between 1 and 12";
63
- },
60
+ default: (_a = (0, sessionDuration_1.parseSessionDurationHours)(profile === null || profile === void 0 ? void 0 : profile.azure_default_duration_hours)) !== null && _a !== void 0 ? _a : 1,
61
+ validate: sessionDuration_1.validateSessionDurationHours,
64
62
  },
65
63
  {
66
64
  type: "input",
@@ -70,12 +68,16 @@ async function configureProfileAsync(profileName) {
70
68
  },
71
69
  ];
72
70
  const answers = await inquirer_1.default.prompt(questions);
71
+ const defaultDurationHours = (0, sessionDuration_1.parseSessionDurationHours)(answers.defaultDurationHours);
72
+ if (defaultDurationHours === null) {
73
+ throw new CLIError_1.CLIError(sessionDuration_1.sessionDurationHoursValidationMessage);
74
+ }
73
75
  await awsConfig_1.awsConfig.setProfileConfigValuesAsync(profileName, {
74
76
  azure_tenant_id: answers.tenantId,
75
77
  azure_app_id_uri: answers.appIdUri,
76
78
  azure_default_username: answers.username,
77
79
  azure_default_role_arn: answers.defaultRoleArn,
78
- azure_default_duration_hours: answers.defaultDurationHours,
80
+ azure_default_duration_hours: String(defaultDurationHours),
79
81
  azure_default_remember_me: answers.rememberMe === "true",
80
82
  region: answers.region,
81
83
  });
package/lib/index.js CHANGED
@@ -6,7 +6,9 @@ process.on("SIGTERM", () => process.exit(1));
6
6
  const commander_1 = require("commander");
7
7
  const configureProfileAsync_1 = require("./configureProfileAsync");
8
8
  const login_1 = require("./login");
9
+ const sensitiveOutput_1 = require("./sensitiveOutput");
9
10
  const updateNotifier_1 = require("./updateNotifier");
11
+ const validateCliOptions_1 = require("./validateCliOptions");
10
12
  // eslint-disable-next-line @typescript-eslint/no-require-imports
11
13
  const { version } = require("../package.json");
12
14
  const program = new commander_1.Command();
@@ -24,6 +26,7 @@ program
24
26
  .option("--enable-chrome-seamless-sso", "Enable Chromium's pass-through authentication with Azure Active Directory Seamless Single Sign-On")
25
27
  .option("--no-disable-extensions", "Tell Puppeteer not to pass the --disable-extensions flag to Chromium")
26
28
  .option("--disable-gpu", "Tell Puppeteer to pass the --disable-gpu flag to Chromium")
29
+ .option("--credential-process", "Output credentials in JSON format for AWS CLI credential_process")
27
30
  .option("--incognito", "Launch Chromium in incognito mode")
28
31
  .parse(process.argv);
29
32
  const options = program.opts();
@@ -39,6 +42,7 @@ const enableChromeSeamlessSso = !!options.enableChromeSeamlessSso;
39
42
  const forceRefresh = !!options.forceRefresh;
40
43
  const noDisableExtensions = !options.disableExtensions;
41
44
  const disableGpu = !!options.disableGpu;
45
+ const credentialProcess = !!options.credentialProcess;
42
46
  const incognito = !!options.incognito;
43
47
  // Start the update lookup immediately, but only print after the main flow ends.
44
48
  const updateCheckPromise = (0, updateNotifier_1.checkForUpdate)(version, {
@@ -47,6 +51,11 @@ const updateCheckPromise = (0, updateNotifier_1.checkForUpdate)(version, {
47
51
  async function runAsync() {
48
52
  let exitCode = 0;
49
53
  try {
54
+ (0, validateCliOptions_1.validateCliOptions)({
55
+ allProfiles: !!options.allProfiles,
56
+ configure: !!options.configure,
57
+ credentialProcess,
58
+ });
50
59
  if (options.allProfiles) {
51
60
  await login_1.login.loginAll(mode, disableSandbox, noPrompt, enableChromeNetworkService, awsNoVerifySsl, enableChromeSeamlessSso, forceRefresh, noDisableExtensions, disableGpu, incognito);
52
61
  }
@@ -54,16 +63,20 @@ async function runAsync() {
54
63
  await (0, configureProfileAsync_1.configureProfileAsync)(profileName);
55
64
  }
56
65
  else {
57
- await login_1.login.loginAsync(profileName, mode, disableSandbox, noPrompt, enableChromeNetworkService, awsNoVerifySsl, enableChromeSeamlessSso, noDisableExtensions, disableGpu, incognito);
66
+ await login_1.login.loginAsync(profileName, mode, disableSandbox, noPrompt, enableChromeNetworkService, awsNoVerifySsl, enableChromeSeamlessSso, noDisableExtensions, disableGpu, incognito, credentialProcess);
58
67
  }
59
68
  }
60
69
  catch (err) {
61
70
  if (err instanceof Error && err.name === "CLIError") {
62
- console.error(err.message);
71
+ console.error((0, sensitiveOutput_1.formatCliErrorMessage)(err.message));
63
72
  exitCode = 2;
64
73
  }
74
+ else if (err instanceof Error) {
75
+ console.error((0, sensitiveOutput_1.formatUnexpectedErrorMessage)(err));
76
+ exitCode = 1;
77
+ }
65
78
  else {
66
- console.error(err);
79
+ console.error((0, sensitiveOutput_1.formatUnexpectedErrorMessage)(err));
67
80
  exitCode = 1;
68
81
  }
69
82
  }