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 +3 -0
- awsuser/mfa_login.py +513 -0
- awsuser/utils.py +49 -0
- awsuser-0.1.0.dist-info/METADATA +119 -0
- awsuser-0.1.0.dist-info/RECORD +8 -0
- awsuser-0.1.0.dist-info/WHEEL +4 -0
- awsuser-0.1.0.dist-info/entry_points.txt +2 -0
- awsuser-0.1.0.dist-info/licenses/LICENSE +21 -0
awsuser/__init__.py
ADDED
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,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.
|