awsuser 0.1.0__py3-none-any.whl

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.
awsuser/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """awsuser — Python CLI for AWS IAM user authentication with MFA."""
2
+
3
+ __version__ = "0.1.0"
awsuser/mfa_login.py ADDED
@@ -0,0 +1,513 @@
1
+ """awsuser — Authenticate as an AWS IAM user with password + MFA via Console sign-in."""
2
+
3
+ import argparse
4
+ import configparser
5
+ import json
6
+ import os
7
+ import platform
8
+ import re
9
+ import stat
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
14
+
15
+ from awsuser.utils import LOG, add_common_arguments, setup_session
16
+
17
+ KEYRING_SERVICE = "awsuser"
18
+ DEFAULT_REGION = "us-east-1"
19
+ def _default_browser() -> str:
20
+ """Return the default browser path based on the OS."""
21
+ system = platform.system()
22
+ if system == "Darwin":
23
+ return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
24
+ if system == "Windows":
25
+ username = os.environ.get("USERNAME", os.environ.get("USER", ""))
26
+ return f"C:\\Users\\{username}\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe"
27
+ return "chromium"
28
+
29
+
30
+ DEFAULT_BROWSER = _default_browser()
31
+
32
+ _ACCOUNT_PATTERN = re.compile(r"^\d{12}$")
33
+
34
+
35
+ def build_parser() -> argparse.ArgumentParser:
36
+ """Construct the argument parser."""
37
+ parser = argparse.ArgumentParser(
38
+ description="Authenticate as an AWS IAM user with password + MFA.",
39
+ )
40
+ add_common_arguments(parser) # --aws-profile, --region, -v
41
+
42
+ parser.add_argument(
43
+ "--account",
44
+ "-a",
45
+ required=True,
46
+ help="AWS account ID (12 digits).",
47
+ )
48
+ parser.add_argument(
49
+ "--user",
50
+ "-u",
51
+ required=True,
52
+ help="IAM username.",
53
+ )
54
+ parser.add_argument(
55
+ "--profile",
56
+ "-p",
57
+ default="default",
58
+ help="Profile name to write credentials to (default: 'default').",
59
+ )
60
+ parser.add_argument(
61
+ "--output",
62
+ choices=["credentials-file", "env"],
63
+ default="credentials-file",
64
+ help="Output format: write to credentials file or print env exports.",
65
+ )
66
+ parser.add_argument(
67
+ "--save",
68
+ action="store_true",
69
+ default=False,
70
+ help="Save the password to the system keyring.",
71
+ )
72
+ parser.add_argument(
73
+ "--debug",
74
+ action="store_true",
75
+ default=False,
76
+ help="Enable debug logging output.",
77
+ )
78
+ parser.add_argument(
79
+ "--browser",
80
+ default=DEFAULT_BROWSER,
81
+ help="Browser executable path or Playwright browser name (chromium, firefox, webkit). "
82
+ "On macOS defaults to Google Chrome.",
83
+ )
84
+
85
+ return parser
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Keyring helpers
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def _get_keyring_password(account: str, user: str) -> str | None:
94
+ """Retrieve IAM user password from system keyring.
95
+
96
+ Key format: {account}/{user}
97
+ """
98
+ key = f"{account}/{user}"
99
+ try:
100
+ import keyring
101
+ password = keyring.get_password(KEYRING_SERVICE, key)
102
+ if password:
103
+ LOG.info("Retrieved password from keyring for %s", key)
104
+ return password
105
+ except ImportError:
106
+ LOG.debug("keyring package not installed, skipping keyring lookup.")
107
+ return None
108
+ except Exception as exc:
109
+ LOG.debug("Failed to read from keyring: %s", exc)
110
+ return None
111
+
112
+
113
+ def _save_keyring_password(account: str, user: str, password: str) -> None:
114
+ """Save IAM user password to system keyring."""
115
+ key = f"{account}/{user}"
116
+ try:
117
+ import keyring
118
+ keyring.set_password(KEYRING_SERVICE, key, password)
119
+ print(f"Password saved to keyring for {key}", file=sys.stderr)
120
+ except ImportError:
121
+ print(
122
+ "Error: keyring package is not installed. "
123
+ "Install it with: pip install keyring",
124
+ file=sys.stderr,
125
+ )
126
+ sys.exit(1)
127
+ except Exception as exc:
128
+ print(f"Error: Failed to save to keyring: {exc}", file=sys.stderr)
129
+ sys.exit(1)
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Password and TOTP input
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def get_password(account: str, user: str) -> str:
138
+ """Get IAM user password from env var, keyring, or interactive prompt.
139
+
140
+ Resolution order:
141
+ 1. USER_PASSWORD environment variable
142
+ 2. System keyring (keyed by account/user)
143
+ 3. Interactive prompt
144
+ """
145
+ # 1. Check USER_PASSWORD env var
146
+ password = os.environ.get("USER_PASSWORD")
147
+ if password is not None and password.strip():
148
+ LOG.info("Using password from USER_PASSWORD environment variable.")
149
+ return password.strip()
150
+
151
+ # 2. Check keyring
152
+ keyring_password = _get_keyring_password(account, user)
153
+ if keyring_password:
154
+ return keyring_password
155
+
156
+ # 3. Interactive prompt
157
+ try:
158
+ import getpass
159
+ password = getpass.getpass("Enter IAM user password: ")
160
+ except EOFError:
161
+ print("Error: no input received (EOF).", file=sys.stderr)
162
+ sys.exit(1)
163
+
164
+ if not password.strip():
165
+ print("Error: password cannot be empty.", file=sys.stderr)
166
+ sys.exit(1)
167
+
168
+ return password.strip()
169
+
170
+
171
+ def prompt_for_token() -> str:
172
+ """Prompt for the 6-digit MFA TOTP code. Always interactive."""
173
+ try:
174
+ token_code = input("Enter MFA token code: ")
175
+ except EOFError:
176
+ print("Error: no input received (EOF).", file=sys.stderr)
177
+ sys.exit(1)
178
+
179
+ token_code = token_code.strip()
180
+
181
+ if not token_code.isdigit() or len(token_code) != 6:
182
+ print("MFA token must be exactly 6 digits.", file=sys.stderr)
183
+ sys.exit(1)
184
+
185
+ return token_code
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Console sign-in flow (Playwright headless browser)
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def console_sign_in(account: str, username: str, password: str, mfa_code: str, region: str, browser_type: str = "chromium") -> dict: # pragma: no cover
194
+ """Authenticate via the AWS Console using a headless browser and return temporary credentials.
195
+
196
+ Uses Playwright to automate the full sign-in flow including MFA.
197
+
198
+ Returns:
199
+ Dict with keys: access_key_id, secret_access_key, session_token, expiration
200
+ """
201
+ signin_url = f"https://{account}.signin.aws.amazon.com/console"
202
+
203
+ with sync_playwright() as p:
204
+ # Determine if browser_type is a path or a Playwright browser name
205
+ if browser_type in ("chromium", "firefox", "webkit"):
206
+ browser_launcher = getattr(p, browser_type)
207
+ browser = browser_launcher.launch(headless=True)
208
+ else:
209
+ # Treat as executable path — use chromium channel with custom path
210
+ browser = p.chromium.launch(headless=True, executable_path=browser_type)
211
+ context = browser.new_context()
212
+ page = context.new_page()
213
+
214
+ try:
215
+ # Navigate to the IAM user sign-in page
216
+ LOG.debug("Navigating to %s", signin_url)
217
+ page.goto(signin_url, timeout=60000)
218
+
219
+ # Fill username
220
+ LOG.debug("Filling username")
221
+ page.get_by_role("textbox", name="IAM username").fill(username)
222
+
223
+ # Fill password
224
+ LOG.debug("Filling password")
225
+ page.get_by_role("textbox", name="Password").fill(password)
226
+
227
+ # Click sign-in button
228
+ LOG.debug("Clicking sign-in")
229
+ page.get_by_test_id("sign-in").click()
230
+
231
+ # Wait for MFA input to appear
232
+ LOG.debug("Waiting for MFA prompt")
233
+ try:
234
+ page.get_by_role("textbox", name="MFA code").wait_for(timeout=15000)
235
+ except PlaywrightTimeout:
236
+ # Check if we're already in the console (no MFA) or there's an error
237
+ if "console.aws.amazon.com" in page.url:
238
+ LOG.info("Sign-in successful (no MFA required)")
239
+ else:
240
+ error_el = page.query_selector('[data-testid="error-message"], .error-message')
241
+ error_text = error_el.inner_text() if error_el else "Unknown error (check credentials)"
242
+ print(f"Error: Sign-in failed: {error_text}", file=sys.stderr)
243
+ sys.exit(1)
244
+ else:
245
+ # Fill MFA code
246
+ LOG.debug("Filling MFA code")
247
+ page.get_by_role("textbox", name="MFA code").fill(mfa_code)
248
+
249
+ # Click MFA submit button
250
+ LOG.debug("Submitting MFA")
251
+ page.get_by_test_id("mfa-submit-button").click()
252
+
253
+ # Wait for console to load
254
+ LOG.debug("Waiting for console to load")
255
+ try:
256
+ page.wait_for_url("**/console/**", timeout=30000)
257
+ LOG.info("Console sign-in successful.")
258
+ except PlaywrightTimeout:
259
+ # Check for error
260
+ error_el = page.query_selector('[data-testid="error-message"], .error-message')
261
+ if error_el:
262
+ print(f"Error: {error_el.inner_text()}", file=sys.stderr)
263
+ else:
264
+ print(f"Error: Timed out waiting for console. URL: {page.url}", file=sys.stderr)
265
+ sys.exit(1)
266
+
267
+ # Determine the region from the current Console URL
268
+ console_region_match = re.search(
269
+ r"https://([^.]+)\.console\.aws\.amazon\.com", page.url
270
+ )
271
+ actual_region = console_region_match.group(1) if console_region_match else region
272
+ LOG.debug("Console region: %s", actual_region)
273
+
274
+ # Navigate to CloudShell via the Console search bar
275
+ LOG.debug("Opening CloudShell via search")
276
+ page.get_by_test_id("awsc-concierge-input").click()
277
+ page.get_by_test_id("awsc-concierge-input").fill("cloudshell")
278
+ page.get_by_test_id("services-search-result-link-scallop").click()
279
+
280
+ # Dismiss welcome dialog if it appears
281
+ try:
282
+ welcome_close = page.get_by_test_id("welcome-close")
283
+ welcome_close.wait_for(timeout=10000)
284
+ welcome_close.click()
285
+ LOG.debug("Dismissed CloudShell welcome dialog")
286
+ except PlaywrightTimeout:
287
+ LOG.debug("No welcome dialog appeared")
288
+
289
+ # Wait for the terminal input to be ready
290
+ LOG.debug("Waiting for CloudShell terminal input")
291
+ terminal_input = page.get_by_role("textbox", name="CloudShell terminal input")
292
+ terminal_input.wait_for(timeout=60000)
293
+ # Give the shell prompt time to appear
294
+ page.wait_for_timeout(3000)
295
+
296
+ # Click the terminal area to focus it
297
+ page.locator(".ace_content").click()
298
+ page.wait_for_timeout(500)
299
+
300
+ # Type the credential export command
301
+ LOG.debug("Typing credential export command")
302
+ terminal_input.fill("")
303
+ terminal_input.type("aws configure export-credentials | jq -c '.'", delay=20)
304
+ terminal_input.press("Enter")
305
+
306
+ # Wait for command to execute
307
+ LOG.debug("Waiting for command output")
308
+ page.wait_for_timeout(5000)
309
+
310
+ # Read terminal content
311
+ terminal_text = page.evaluate("""() => {
312
+ const ace = document.querySelector('.ace_content');
313
+ if (ace) return ace.innerText;
314
+ return '';
315
+ }""")
316
+
317
+ LOG.debug("Terminal text length: %d", len(terminal_text) if terminal_text else 0)
318
+ LOG.debug("Terminal text (last 1500): %s", terminal_text[-1500:] if terminal_text else "empty")
319
+
320
+ # Parse credentials from output
321
+ creds_data = None
322
+
323
+ if terminal_text:
324
+ # The output is JSON from `aws configure export-credentials`
325
+ # Find JSON by looking for the opening { before "AccessKeyId" or "Version"
326
+ # and the closing } after "Expiration"
327
+ # Try to find a complete JSON object
328
+ start_idx = terminal_text.find('{"')
329
+ if start_idx == -1:
330
+ start_idx = terminal_text.find('{\n')
331
+ if start_idx >= 0:
332
+ # Find the matching closing brace
333
+ brace_count = 0
334
+ end_idx = start_idx
335
+ for i in range(start_idx, len(terminal_text)):
336
+ if terminal_text[i] == '{':
337
+ brace_count += 1
338
+ elif terminal_text[i] == '}':
339
+ brace_count -= 1
340
+ if brace_count == 0:
341
+ end_idx = i + 1
342
+ break
343
+
344
+ if end_idx > start_idx:
345
+ json_str = terminal_text[start_idx:end_idx]
346
+ # Strip terminal control characters
347
+ json_str = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', json_str)
348
+ try:
349
+ parsed = json.loads(json_str)
350
+ if "AccessKeyId" in parsed or "SessionToken" in parsed:
351
+ creds_data = {
352
+ "access_key_id": parsed.get("AccessKeyId", ""),
353
+ "secret_access_key": parsed.get("SecretAccessKey", ""),
354
+ "session_token": parsed.get("SessionToken", parsed.get("Token", "")),
355
+ "expiration": parsed.get("Expiration", "unknown"),
356
+ }
357
+ LOG.info("Extracted credentials from CloudShell")
358
+ except json.JSONDecodeError as e:
359
+ LOG.debug("JSON parse failed: %s", e)
360
+
361
+ # Fallback: try export KEY=VALUE format
362
+ if not creds_data:
363
+ ak_match = re.search(r'export AWS_ACCESS_KEY_ID=(\S+)', terminal_text)
364
+ sk_match = re.search(r'export AWS_SECRET_ACCESS_KEY=(\S+)', terminal_text)
365
+ st_match = re.search(r'export AWS_SESSION_TOKEN=(\S+)', terminal_text)
366
+ if ak_match and sk_match and st_match:
367
+ creds_data = {
368
+ "access_key_id": ak_match.group(1),
369
+ "secret_access_key": sk_match.group(1),
370
+ "session_token": st_match.group(1),
371
+ "expiration": "unknown",
372
+ }
373
+ LOG.info("Extracted credentials from export format")
374
+
375
+ if not creds_data:
376
+ print("Error: Could not extract credentials from CloudShell.", file=sys.stderr)
377
+ print("Try running 'aws configure export-credentials' manually in CloudShell.", file=sys.stderr)
378
+ sys.exit(1)
379
+
380
+ return creds_data
381
+
382
+ finally:
383
+ browser.close()
384
+
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # Credential output
388
+ # ---------------------------------------------------------------------------
389
+
390
+
391
+ def format_env_export(credentials: dict) -> str:
392
+ """Format credentials as shell export statements."""
393
+
394
+ def _escape(value: str) -> str:
395
+ return value.replace("'", "'\\''")
396
+
397
+ lines = [
398
+ f"export AWS_ACCESS_KEY_ID='{_escape(credentials['access_key_id'])}'",
399
+ f"export AWS_SECRET_ACCESS_KEY='{_escape(credentials['secret_access_key'])}'",
400
+ f"export AWS_SESSION_TOKEN='{_escape(credentials['session_token'])}'",
401
+ ]
402
+ return "\n".join(lines)
403
+
404
+
405
+ def write_credentials_to_file(
406
+ credentials: dict, profile_name: str, credentials_path: Path | None = None
407
+ ) -> None:
408
+ """Write temporary credentials to the AWS credentials file."""
409
+ if credentials_path is None:
410
+ credentials_path = Path.home() / ".aws" / "credentials"
411
+
412
+ LOG.debug("Credentials path: %s", credentials_path)
413
+
414
+ # Ensure ~/.aws/ directory exists with mode 700
415
+ aws_dir = credentials_path.parent
416
+ try:
417
+ if not aws_dir.exists():
418
+ aws_dir.mkdir(mode=0o700, parents=True)
419
+ LOG.debug("Created directory: %s (mode 700)", aws_dir)
420
+ except OSError as exc:
421
+ print(f"Error: Unable to create directory '{aws_dir}': {exc}", file=sys.stderr)
422
+ sys.exit(1)
423
+
424
+ # Check existing file permissions
425
+ if credentials_path.exists():
426
+ try:
427
+ file_stat = os.stat(credentials_path)
428
+ file_mode = stat.S_IMODE(file_stat.st_mode)
429
+ if file_mode & 0o177:
430
+ print(
431
+ f"Warning: '{credentials_path}' has permissions "
432
+ f"{oct(file_mode)} which are more permissive than 600. "
433
+ f"Consider running: chmod 600 {credentials_path}",
434
+ file=sys.stderr,
435
+ )
436
+ except OSError as exc:
437
+ LOG.debug("Could not check file permissions: %s", exc)
438
+
439
+ # Read existing credentials file
440
+ config = configparser.ConfigParser()
441
+ try:
442
+ config.read(str(credentials_path))
443
+ except (configparser.Error, OSError) as exc:
444
+ LOG.debug("Could not read existing credentials file: %s", exc)
445
+ config = configparser.ConfigParser()
446
+
447
+ # Create or update the target profile section
448
+ if not config.has_section(profile_name):
449
+ config.add_section(profile_name)
450
+
451
+ config.set(profile_name, "aws_access_key_id", credentials["access_key_id"])
452
+ config.set(profile_name, "aws_secret_access_key", credentials["secret_access_key"])
453
+ config.set(profile_name, "aws_session_token", credentials["session_token"])
454
+
455
+ # Write the credentials file
456
+ try:
457
+ with open(credentials_path, "w", encoding="utf-8") as f:
458
+ config.write(f)
459
+ except OSError as exc:
460
+ print(f"Error: Unable to write credentials file '{credentials_path}': {exc}", file=sys.stderr)
461
+ sys.exit(1)
462
+
463
+ # Display confirmation
464
+ expiration = credentials["expiration"]
465
+ if hasattr(expiration, "isoformat"):
466
+ expiration_str = expiration.isoformat()
467
+ else:
468
+ expiration_str = str(expiration)
469
+
470
+ print(f"Credentials written to profile [{profile_name}]. Expires: {expiration_str}", file=sys.stderr)
471
+
472
+
473
+ # ---------------------------------------------------------------------------
474
+ # Main entry point
475
+ # ---------------------------------------------------------------------------
476
+
477
+
478
+ def _main() -> None:
479
+ """Entry point registered as console script 'awsuser'."""
480
+ parser = build_parser()
481
+ args = parser.parse_args()
482
+
483
+ # Setup logging
484
+ if args.debug:
485
+ args.verbose = 3
486
+ setup_session(args)
487
+
488
+ # Validate account ID
489
+ if not _ACCOUNT_PATTERN.match(args.account):
490
+ print(f"Error: Invalid account ID '{args.account}'. Must be exactly 12 digits.", file=sys.stderr)
491
+ sys.exit(1)
492
+
493
+ region = args.region or DEFAULT_REGION
494
+
495
+ # Get password (env var, keyring, or prompt)
496
+ password = get_password(args.account, args.user)
497
+
498
+ # Save password to keyring if --save was specified
499
+ if args.save:
500
+ _save_keyring_password(args.account, args.user, password)
501
+
502
+ # Always prompt for TOTP code (rotates every 30s, never stored)
503
+ token_code = prompt_for_token()
504
+
505
+ # Authenticate via Console sign-in flow
506
+ LOG.info("Authenticating as %s in account %s...", args.user, args.account)
507
+ credentials = console_sign_in(args.account, args.user, password, token_code, region, args.browser)
508
+
509
+ # Output credentials
510
+ if args.output == "env":
511
+ print(format_env_export(credentials))
512
+ else:
513
+ write_credentials_to_file(credentials, args.profile)
awsuser/utils.py ADDED
@@ -0,0 +1,49 @@
1
+ """Shared utilities for awsuser commands."""
2
+
3
+ import argparse
4
+ import logging
5
+
6
+ LOG = logging.getLogger("awsuser")
7
+
8
+ # Disk cache directory — credentials must NOT be stored here
9
+ CACHE_DIR = "~/.awsuser"
10
+
11
+ # Simple dict-based cache for non-sensitive values
12
+ CACHE: dict = {}
13
+
14
+
15
+ def set_level(verbosity: int) -> None:
16
+ """Set logging level based on verbosity count."""
17
+ if verbosity >= 3:
18
+ logging.basicConfig(level=logging.DEBUG)
19
+ elif verbosity >= 2:
20
+ logging.basicConfig(level=logging.DEBUG)
21
+ elif verbosity >= 1:
22
+ logging.basicConfig(level=logging.INFO)
23
+ else:
24
+ logging.basicConfig(level=logging.WARNING)
25
+
26
+ # Suppress noisy keyring backend discovery logs
27
+ logging.getLogger("keyring.backend").setLevel(logging.WARNING)
28
+
29
+
30
+ def add_common_arguments(parser: argparse.ArgumentParser) -> None:
31
+ """Add standard --region, -v flags to a parser."""
32
+ parser.add_argument(
33
+ "--region",
34
+ default=None,
35
+ help="AWS region (default: us-east-1).",
36
+ )
37
+ parser.add_argument(
38
+ "-v",
39
+ action="count",
40
+ default=0,
41
+ dest="verbose",
42
+ help="Increase verbosity (repeatable: -v, -vv, -vvv).",
43
+ )
44
+
45
+
46
+ def setup_session(args: argparse.Namespace) -> None:
47
+ """Configure logging from CLI arguments."""
48
+ set_level(getattr(args, "verbose", 0))
49
+ LOG.debug("Logging configured, verbosity=%s", getattr(args, "verbose", 0))
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: awsuser
3
+ Version: 0.1.0
4
+ Summary: CLI tool to authenticate AWS IAM users with password + MFA via Console sign-in (no access keys needed)
5
+ Project-URL: Homepage, https://github.com/tmb28054/awsuser
6
+ Project-URL: Repository, https://github.com/tmb28054/awsuser
7
+ Project-URL: Issues, https://github.com/tmb28054/awsuser/issues
8
+ Project-URL: Changelog, https://github.com/tmb28054/awsuser/blob/main/CHANGELOG.md
9
+ Author: Topaz Bott
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: auth,aws,cli,user
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Environment :: MacOS X
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: System Administrators
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: MacOS :: MacOS X
20
+ Classifier: Operating System :: Microsoft :: Windows
21
+ Classifier: Operating System :: POSIX
22
+ Classifier: Operating System :: POSIX :: Linux
23
+ Classifier: Programming Language :: Python :: 3
24
+ Classifier: Programming Language :: Python :: 3.10
25
+ Classifier: Programming Language :: Python :: 3.11
26
+ Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
28
+ Classifier: Topic :: Security
29
+ Classifier: Topic :: System :: Systems Administration
30
+ Classifier: Typing :: Typed
31
+ Requires-Python: >=3.10
32
+ Requires-Dist: keyring>=23.0
33
+ Requires-Dist: playwright>=1.40
34
+ Provides-Extra: dev
35
+ Requires-Dist: bandit>=1.7; extra == 'dev'
36
+ Requires-Dist: pylint>=3.0; extra == 'dev'
37
+ Provides-Extra: test
38
+ Requires-Dist: pytest-cov>=4.0; extra == 'test'
39
+ Requires-Dist: pytest>=7.0; extra == 'test'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # awsuser
43
+
44
+ Python CLI for AWS IAM user authentication with password + MFA. Automates the AWS Console sign-in flow using a headless browser to obtain temporary credentials — no pre-existing access keys needed.
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install awsuser
50
+ playwright install chromium
51
+ ```
52
+
53
+ Or from source:
54
+
55
+ ```bash
56
+ pip install -e ".[test]"
57
+ playwright install chromium
58
+ ```
59
+
60
+ > **Note:** On macOS and Windows, `awsuser` uses your installed Google Chrome by default. You only need `playwright install chromium` if Chrome isn't installed or you want to use the bundled Chromium.
61
+
62
+ ## Usage
63
+
64
+ ```bash
65
+ # Basic usage (prompts for password and MFA code)
66
+ awsuser -a 123456789012 -u alice
67
+
68
+ # Save password to system keyring for next time
69
+ awsuser -a 123456789012 -u alice --save
70
+
71
+ # Output as environment variables
72
+ source <(awsuser -a 123456789012 -u alice --output env)
73
+
74
+ # Custom profile name
75
+ awsuser -a 123456789012 -u alice -p myprofile
76
+
77
+ # With specific region
78
+ awsuser -a 123456789012 -u alice --region us-west-2
79
+
80
+ # Use a specific browser
81
+ awsuser -a 123456789012 -u alice --browser firefox
82
+
83
+ # Debug mode (verbose logging)
84
+ awsuser -a 123456789012 -u alice --debug
85
+ ```
86
+
87
+ ## How It Works
88
+
89
+ 1. Prompts for your IAM user password (or reads from keyring/`USER_PASSWORD` env var)
90
+ 2. Prompts for your 6-digit MFA TOTP code
91
+ 3. Opens a headless browser (Google Chrome on macOS/Windows, Chromium on Linux)
92
+ 4. Automates the AWS Console sign-in flow (account + username + password + MFA)
93
+ 5. Opens CloudShell and runs `aws configure export-credentials`
94
+ 6. Extracts temporary credentials and writes them to `~/.aws/credentials`
95
+
96
+ ## Running Tests
97
+
98
+ ```bash
99
+ ./run_tests.sh
100
+ ```
101
+
102
+ Or manually:
103
+
104
+ ```bash
105
+ python3 -m pytest tests/ --cov=awsuser --cov-report=term-missing --cov-fail-under=80 -v
106
+ ```
107
+
108
+ ## Documentation
109
+
110
+ - [User Guide](docs/user-guide.md)
111
+ - [FAQ](docs/faq.md)
112
+
113
+ ## License
114
+
115
+ MIT
116
+
117
+ ## Disclaimer
118
+
119
+ This tool automates the AWS Console sign-in flow using browser automation. It relies on the Console's UI structure which AWS may change without notice. If the tool stops working after an AWS Console update, please open an issue.
@@ -0,0 +1,8 @@
1
+ awsuser/__init__.py,sha256=lAXuJP5v302eJEbSW2EBpX8ipxpcBWy4I5Bd9n2yMPU,94
2
+ awsuser/mfa_login.py,sha256=kb4QYBFfxEMkKP1L7g-0ctjNUuYrcc8bDp2m_Ap8wh0,19657
3
+ awsuser/utils.py,sha256=VhM8Z9BkDNR9RIFZevcpzj7-hQ8mvBjK2HxCdUXvmLA,1413
4
+ awsuser-0.1.0.dist-info/METADATA,sha256=NbvjcBAlVabsZsPSQxPCIDLxGeIqJa08hrIP5X7aLVM,3779
5
+ awsuser-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
6
+ awsuser-0.1.0.dist-info/entry_points.txt,sha256=ZcCMfmZKG8AVedlxXIIYoEvY0QFSJnDV4uePrwR15-g,52
7
+ awsuser-0.1.0.dist-info/licenses/LICENSE,sha256=7yxHIYjGY_yLY2SAqQQui8YVj6rF73PTvGL2Xref8jY,1077
8
+ awsuser-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ awsuser = awsuser.mfa_login:_main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 awsuser contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.