az2aws 1.6.1 → 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,30 @@
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
+
21
+ ## [1.6.2](https://github.com/kuma0128/az2aws/compare/v1.6.1...v1.6.2) (2026-04-10)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * **deps:** remove unnecessary dependency for security hardening ([#156](https://github.com/kuma0128/az2aws/issues/156)) ([b1ac64d](https://github.com/kuma0128/az2aws/commit/b1ac64d358197db094603dbc948c5f49cfa78c88))
27
+
3
28
  ## [1.6.1](https://github.com/kuma0128/az2aws/compare/v1.6.0...v1.6.1) (2026-04-02)
4
29
 
5
30
 
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
@@ -7,6 +7,17 @@
7
7
 
8
8
  Log in to AWS CLI using [Azure Active Directory](https://azure.microsoft.com) SSO. Supports MFA and places temporary credentials in the proper location for AWS CLI and SDKs.
9
9
 
10
+ > **💡 Tip:** Let's be honest — typing `az2aws` correctly on the first try is harder than the AWS certification exam. Save your sanity:
11
+ >
12
+ > ```sh
13
+ > # Add to your ~/.zshrc or ~/.bashrc
14
+ > alias a2='az2aws'
15
+ > # or
16
+ > alias aa='az2aws'
17
+ > ```
18
+ >
19
+ > Your fingers will thank you. Your keyboard will thank you. Your coworkers will stop hearing you swear.
20
+
10
21
  ## Installation
11
22
 
12
23
  ### mise (Recommended)
@@ -56,9 +67,13 @@ You must install [puppeteer dependencies](https://github.com/GoogleChrome/puppet
56
67
 
57
68
  #### Windows Notes
58
69
 
59
- 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
+
72
+ node <npm_global_node_modules>\az2aws\node_modules\puppeteer\install.mjs
60
73
 
61
- node <node_modules_dir>/az2aws/node_modules/puppeteer/install.js
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`.
62
77
 
63
78
  ### Docker
64
79
 
@@ -101,6 +116,7 @@ https://snapcraft.io/az2aws
101
116
  | `--no-disable-extensions` | Keep browser extensions enabled |
102
117
  | `--disable-gpu` | Disable GPU acceleration |
103
118
  | `--incognito` | Open the login flow in an incognito browser context |
119
+ | `--credential-process` | Output credentials for AWS CLI credential_process |
104
120
  | `--version (-v)` | Show version number |
105
121
 
106
122
  ## Usage
@@ -137,6 +153,29 @@ Enable "Stay logged in" during configuration to use `--no-prompt` without storin
137
153
  helps avoid reusing an existing browser session, and it overrides any saved
138
154
  "Stay logged in" browser state for that run.
139
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
+
140
179
  #### Environment Variables
141
180
 
142
181
  You can set defaults via environment variables (use with `--no-prompt`):
@@ -181,10 +220,15 @@ Example:
181
220
  az2aws # Default profile
182
221
  az2aws --profile foo # Named profile
183
222
  az2aws --mode gui # Use browser UI (more reliable)
223
+ az2aws --mode debug # Show the browser while az2aws still drives the flow
184
224
  az2aws --mode gui --incognito # Open a fresh incognito login window
185
225
 
186
226
  You'll be prompted for username, password, and MFA if required. After login, use AWS CLI/SDKs as usual.
187
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
+
188
232
  **Tips:**
189
233
  - Set `AWS_PROFILE` env var instead of using `--profile`
190
234
  - Use `--mode gui --disable-gpu` on VMs or if rendering fails
@@ -206,6 +250,13 @@ If you see device compliance errors (e.g., "Device UnSecured Or Non-Compliant"),
206
250
  Try:
207
251
  `--mode gui` and use your system Chrome via `BROWSER_CHROME_BIN`.
208
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
+
209
260
  If you see "Unable to recognize page state!", Azure's login pages may have
210
261
  changed. Try:
211
262
 
package/lib/awsConfig.js CHANGED
@@ -7,11 +7,81 @@ exports.awsConfig = void 0;
7
7
  const ini_1 = __importDefault(require("ini"));
8
8
  const debug_1 = __importDefault(require("debug"));
9
9
  const paths_1 = require("./paths");
10
- const mkdirp_1 = require("mkdirp");
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, mkdirp_1.mkdirp)(paths_1.paths.awsDir);
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
  }