idp2aws 0.2.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.
- idp2aws/__init__.py +3 -0
- idp2aws/aws.py +205 -0
- idp2aws/browser.py +183 -0
- idp2aws/cli.py +256 -0
- idp2aws/files.py +30 -0
- idp2aws/saml.py +87 -0
- idp2aws/settings.py +202 -0
- idp2aws-0.2.0.dist-info/METADATA +127 -0
- idp2aws-0.2.0.dist-info/RECORD +12 -0
- idp2aws-0.2.0.dist-info/WHEEL +4 -0
- idp2aws-0.2.0.dist-info/entry_points.txt +2 -0
- idp2aws-0.2.0.dist-info/licenses/LICENSE +44 -0
idp2aws/__init__.py
ADDED
idp2aws/aws.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Module for swapping a SAML response for AWS keys, and storing them."""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlencode
|
|
11
|
+
from urllib.request import urlopen
|
|
12
|
+
|
|
13
|
+
import boto3
|
|
14
|
+
from botocore import UNSIGNED
|
|
15
|
+
from botocore.config import Config
|
|
16
|
+
|
|
17
|
+
from idp2aws.files import write_file
|
|
18
|
+
from idp2aws.saml import Assertion, Role
|
|
19
|
+
|
|
20
|
+
FEDERATION_URL = "https://signin.aws.amazon.com/federation"
|
|
21
|
+
# Keys this close to expiry count as expired, so a command started with them
|
|
22
|
+
# does not fail halfway through.
|
|
23
|
+
EXPIRY_MARGIN = timedelta(minutes=5)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Credentials:
|
|
28
|
+
"""Short-lived AWS keys.
|
|
29
|
+
|
|
30
|
+
:param access_key_id: The ``ASIA...`` key id.
|
|
31
|
+
:param secret_access_key: The secret key.
|
|
32
|
+
:param session_token: The session token that goes with the keys.
|
|
33
|
+
:param expiration: When the keys stop working.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
access_key_id: str
|
|
37
|
+
secret_access_key: str
|
|
38
|
+
session_token: str
|
|
39
|
+
expiration: datetime
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_expired(self) -> bool:
|
|
43
|
+
"""Tell whether the keys have stopped working, or soon will.
|
|
44
|
+
|
|
45
|
+
:return: ``True`` from ``EXPIRY_MARGIN`` before the expiry time.
|
|
46
|
+
"""
|
|
47
|
+
return datetime.now(timezone.utc) >= self.expiration - EXPIRY_MARGIN
|
|
48
|
+
|
|
49
|
+
def as_env(self) -> dict[str, str]:
|
|
50
|
+
"""Get the keys as the environment variables AWS tools read.
|
|
51
|
+
|
|
52
|
+
:return: Variable names and values.
|
|
53
|
+
"""
|
|
54
|
+
return {
|
|
55
|
+
"AWS_ACCESS_KEY_ID": self.access_key_id,
|
|
56
|
+
"AWS_SECRET_ACCESS_KEY": self.secret_access_key,
|
|
57
|
+
"AWS_SESSION_TOKEN": self.session_token,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def as_json(self) -> str:
|
|
61
|
+
"""Get the keys as JSON in the shape ``credential_process`` expects.
|
|
62
|
+
|
|
63
|
+
:return: The JSON text.
|
|
64
|
+
"""
|
|
65
|
+
return json.dumps({
|
|
66
|
+
"Version": 1,
|
|
67
|
+
"AccessKeyId": self.access_key_id,
|
|
68
|
+
"SecretAccessKey": self.secret_access_key,
|
|
69
|
+
"SessionToken": self.session_token,
|
|
70
|
+
"Expiration": self.expiration.isoformat(),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_sts_region(destination: str | None) -> str:
|
|
75
|
+
"""Choose the region for the STS call from where the response is going.
|
|
76
|
+
|
|
77
|
+
:param destination: AWS sign-in address from the SAML response. Its
|
|
78
|
+
domain tells us the partition: commercial, GovCloud or China.
|
|
79
|
+
:return: A region in that partition.
|
|
80
|
+
"""
|
|
81
|
+
if destination and "amazonaws-us-gov" in destination:
|
|
82
|
+
return "us-gov-west-1"
|
|
83
|
+
if destination and "amazonaws.cn" in destination:
|
|
84
|
+
return "cn-north-1"
|
|
85
|
+
return "us-east-1"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def assume_role(
|
|
89
|
+
role: Role, assertion: Assertion, duration_secs: int, region: str
|
|
90
|
+
) -> Credentials:
|
|
91
|
+
"""Ask AWS STS for keys in exchange for the SAML response.
|
|
92
|
+
|
|
93
|
+
The call needs no AWS keys, so the request is not signed.
|
|
94
|
+
|
|
95
|
+
:param role: Role to assume.
|
|
96
|
+
:param assertion: SAML response that lists the role.
|
|
97
|
+
:param duration_secs: How long the keys should last.
|
|
98
|
+
:param region: Region of the STS endpoint.
|
|
99
|
+
:return: The new keys.
|
|
100
|
+
:raises botocore.exceptions.ClientError: If STS refuses.
|
|
101
|
+
"""
|
|
102
|
+
unsigned = Config(signature_version=UNSIGNED)
|
|
103
|
+
sts = boto3.client("sts", region_name=region, config=unsigned)
|
|
104
|
+
response = sts.assume_role_with_saml(
|
|
105
|
+
RoleArn=role.role_arn,
|
|
106
|
+
PrincipalArn=role.principal_arn,
|
|
107
|
+
SAMLAssertion=assertion.raw,
|
|
108
|
+
DurationSeconds=duration_secs,
|
|
109
|
+
)
|
|
110
|
+
keys = response["Credentials"]
|
|
111
|
+
return Credentials(
|
|
112
|
+
access_key_id=keys["AccessKeyId"],
|
|
113
|
+
secret_access_key=keys["SecretAccessKey"],
|
|
114
|
+
session_token=keys["SessionToken"],
|
|
115
|
+
expiration=keys["Expiration"],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_credentials_file() -> Path:
|
|
120
|
+
"""Find the AWS credentials file, honouring ``AWS_SHARED_CREDENTIALS_FILE``.
|
|
121
|
+
|
|
122
|
+
:return: Path to the file. It may not exist yet.
|
|
123
|
+
"""
|
|
124
|
+
default = Path.home() / ".aws" / "credentials"
|
|
125
|
+
return Path(os.environ.get("AWS_SHARED_CREDENTIALS_FILE", default))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def load_credentials(profile: str, expiration: datetime) -> Credentials | None:
|
|
129
|
+
"""Read keys saved to a profile earlier.
|
|
130
|
+
|
|
131
|
+
:param profile: Profile in the AWS credentials file.
|
|
132
|
+
:param expiration: When the keys expire. The file does not say.
|
|
133
|
+
:return: The keys, or ``None`` if the profile is missing or incomplete.
|
|
134
|
+
"""
|
|
135
|
+
parser = _read_ini(get_credentials_file())
|
|
136
|
+
if profile not in parser:
|
|
137
|
+
return None
|
|
138
|
+
section = parser[profile]
|
|
139
|
+
try:
|
|
140
|
+
return Credentials(
|
|
141
|
+
access_key_id=section["aws_access_key_id"],
|
|
142
|
+
secret_access_key=section["aws_secret_access_key"],
|
|
143
|
+
session_token=section["aws_session_token"],
|
|
144
|
+
expiration=expiration,
|
|
145
|
+
)
|
|
146
|
+
except KeyError:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def save_credentials(profile: str, credentials: Credentials) -> None:
|
|
151
|
+
"""Write keys to a profile in the AWS credentials file.
|
|
152
|
+
|
|
153
|
+
Other profiles are kept. Comments in the file are not, because
|
|
154
|
+
``configparser`` drops them. Only you can read the file afterwards.
|
|
155
|
+
|
|
156
|
+
:param profile: Profile to write. Anything already in it is replaced.
|
|
157
|
+
:param credentials: Keys to write.
|
|
158
|
+
"""
|
|
159
|
+
path = get_credentials_file()
|
|
160
|
+
parser = _read_ini(path)
|
|
161
|
+
parser[profile] = {
|
|
162
|
+
"aws_access_key_id": credentials.access_key_id,
|
|
163
|
+
"aws_secret_access_key": credentials.secret_access_key,
|
|
164
|
+
"aws_session_token": credentials.session_token,
|
|
165
|
+
}
|
|
166
|
+
text = io.StringIO()
|
|
167
|
+
parser.write(text)
|
|
168
|
+
write_file(path, text.getvalue(), mode=0o600)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_console_url(credentials: Credentials) -> str:
|
|
172
|
+
"""Build a link that opens the AWS console with these keys.
|
|
173
|
+
|
|
174
|
+
Only works in the commercial AWS partition.
|
|
175
|
+
|
|
176
|
+
:param credentials: Keys to sign in with.
|
|
177
|
+
:return: A one-time sign-in link, valid for 15 minutes.
|
|
178
|
+
:raises OSError: If the AWS sign-in service cannot be reached.
|
|
179
|
+
"""
|
|
180
|
+
session = json.dumps({
|
|
181
|
+
"sessionId": credentials.access_key_id,
|
|
182
|
+
"sessionKey": credentials.secret_access_key,
|
|
183
|
+
"sessionToken": credentials.session_token,
|
|
184
|
+
})
|
|
185
|
+
query = urlencode({"Action": "getSigninToken", "Session": session})
|
|
186
|
+
with urlopen(f"{FEDERATION_URL}?{query}", timeout=10) as response:
|
|
187
|
+
token = json.load(response)["SigninToken"]
|
|
188
|
+
query = urlencode({
|
|
189
|
+
"Action": "login",
|
|
190
|
+
"Issuer": "idp2aws",
|
|
191
|
+
"Destination": "https://console.aws.amazon.com/",
|
|
192
|
+
"SigninToken": token,
|
|
193
|
+
})
|
|
194
|
+
return f"{FEDERATION_URL}?{query}"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _read_ini(path: Path) -> configparser.ConfigParser:
|
|
198
|
+
"""Read an INI file, treating ``%`` as plain text.
|
|
199
|
+
|
|
200
|
+
:param path: File to read. A missing file gives an empty parser.
|
|
201
|
+
:return: The parsed file.
|
|
202
|
+
"""
|
|
203
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
204
|
+
parser.read(path)
|
|
205
|
+
return parser
|
idp2aws/browser.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Module for signing in with a real browser and catching the SAML response.
|
|
2
|
+
|
|
3
|
+
The browser opens the start URL of your IdP. You sign in as usual, with any
|
|
4
|
+
kind of 2-Step Verification. Then the IdP posts the SAML response to the AWS
|
|
5
|
+
sign-in page. Two things can happen next:
|
|
6
|
+
|
|
7
|
+
* You have more than one role and no role is chosen yet. We let the post go
|
|
8
|
+
to AWS, and AWS shows its role page, with the accounts sorted by name. When
|
|
9
|
+
you click *Sign In*, the page posts the SAML response again, with the role
|
|
10
|
+
that you picked. We stop this second post.
|
|
11
|
+
* In all other cases, we stop the first post.
|
|
12
|
+
|
|
13
|
+
The browser profile stays between runs. While your IdP session is active,
|
|
14
|
+
the window opens and closes without input from you.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from urllib.parse import parse_qs
|
|
22
|
+
|
|
23
|
+
from playwright.sync_api import BrowserContext, Request, Route, sync_playwright
|
|
24
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
25
|
+
|
|
26
|
+
from idp2aws import saml
|
|
27
|
+
from idp2aws.settings import HOME
|
|
28
|
+
|
|
29
|
+
PROFILE_DIR = HOME / "browser-profile"
|
|
30
|
+
AWS_SAML_URL = re.compile(
|
|
31
|
+
r"^https://([a-z0-9-]+\.)?signin\."
|
|
32
|
+
r"(aws\.amazon\.com|amazonaws-us-gov\.com|amazonaws\.cn)/saml/?(\?.*)?$"
|
|
33
|
+
)
|
|
34
|
+
# Playwright starts Chrome with these by default. They make Chrome keep the
|
|
35
|
+
# cookies in the browser profile with a fixed key, instead of a key from the
|
|
36
|
+
# macOS Keychain or the Linux keyring.
|
|
37
|
+
WEAK_COOKIE_KEY_ARGS = ["--use-mock-keychain", "--password-store=basic"]
|
|
38
|
+
SORT_ACCOUNTS_SCRIPT = """
|
|
39
|
+
if (location.pathname === "/saml" && location.hostname.includes("signin.")) {
|
|
40
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
41
|
+
const fieldset = document.querySelector("#saml_form fieldset");
|
|
42
|
+
if (!fieldset) return;
|
|
43
|
+
const name = (account) =>
|
|
44
|
+
(account.querySelector(".saml-account-name")?.textContent ?? "")
|
|
45
|
+
.replace("Account:", "").trim();
|
|
46
|
+
const accounts = [...fieldset.querySelectorAll(":scope > div.saml-account")];
|
|
47
|
+
accounts.sort((a, b) => name(a).localeCompare(name(b)));
|
|
48
|
+
fieldset.append(...accounts);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LoginError(Exception):
|
|
55
|
+
"""Raise when the browser login ends without a SAML response."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class BrowserLogin:
|
|
60
|
+
"""What the browser login produced.
|
|
61
|
+
|
|
62
|
+
:param saml_response: The base64 ``SAMLResponse`` value.
|
|
63
|
+
:param picked_role_arn: Role picked on the AWS page, or ``None`` if the
|
|
64
|
+
page was not shown.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
saml_response: str
|
|
68
|
+
picked_role_arn: str | None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def log_in(
|
|
72
|
+
start_url: str,
|
|
73
|
+
browser: str = "chrome",
|
|
74
|
+
role_arn: str | None = None,
|
|
75
|
+
timeout_secs: int = 300,
|
|
76
|
+
) -> BrowserLogin:
|
|
77
|
+
"""Open a browser at the IdP and wait for the login to reach AWS.
|
|
78
|
+
|
|
79
|
+
:param start_url: Start URL of your IdP for AWS.
|
|
80
|
+
:param browser: ``chrome`` or ``msedge`` for your installed Chrome or
|
|
81
|
+
Edge, or ``chromium`` for the one from ``playwright install chromium``.
|
|
82
|
+
:param role_arn: Role already chosen. If set, the AWS role page is skipped.
|
|
83
|
+
:param timeout_secs: How long to wait for you to finish.
|
|
84
|
+
:return: The SAML response, and the role if you picked one.
|
|
85
|
+
:raises LoginError: If the window is closed or the time runs out.
|
|
86
|
+
"""
|
|
87
|
+
result = []
|
|
88
|
+
|
|
89
|
+
def catch(route: Route, request: Request) -> None:
|
|
90
|
+
if request.method != "POST":
|
|
91
|
+
route.fallback()
|
|
92
|
+
return
|
|
93
|
+
form = parse_qs(request.post_data or "")
|
|
94
|
+
saml_response = form.get("SAMLResponse", [""])[0]
|
|
95
|
+
picked = form.get("roleIndex", [None])[0]
|
|
96
|
+
if not (picked or role_arn) and _has_several_roles(saml_response):
|
|
97
|
+
route.fallback() # Let AWS show its role page.
|
|
98
|
+
return
|
|
99
|
+
result.append(BrowserLogin(saml_response, picked))
|
|
100
|
+
route.abort()
|
|
101
|
+
|
|
102
|
+
with sync_playwright() as playwright:
|
|
103
|
+
try:
|
|
104
|
+
context = playwright.chromium.launch_persistent_context(
|
|
105
|
+
PROFILE_DIR,
|
|
106
|
+
channel=None if browser == "chromium" else browser,
|
|
107
|
+
headless=False,
|
|
108
|
+
# Playwright turns the sandbox off by default, and Chrome then
|
|
109
|
+
# warns about the "--no-sandbox" flag.
|
|
110
|
+
chromium_sandbox=True,
|
|
111
|
+
# "--enable-automation" shows the "controlled by automated test
|
|
112
|
+
# software" bar, which can make Google say the browser "may not
|
|
113
|
+
# be secure".
|
|
114
|
+
ignore_default_args=["--enable-automation", *WEAK_COOKIE_KEY_ARGS],
|
|
115
|
+
)
|
|
116
|
+
except PlaywrightError as error:
|
|
117
|
+
raise LoginError(_explain_launch_error(error, browser)) from None
|
|
118
|
+
try:
|
|
119
|
+
context.add_init_script(SORT_ACCOUNTS_SCRIPT)
|
|
120
|
+
context.route(AWS_SAML_URL, catch)
|
|
121
|
+
page = context.pages[0] if context.pages else context.new_page()
|
|
122
|
+
page.goto(start_url)
|
|
123
|
+
_wait_until(lambda: bool(result), context, timeout_secs)
|
|
124
|
+
finally:
|
|
125
|
+
context.close()
|
|
126
|
+
|
|
127
|
+
return result[0]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _explain_launch_error(error: PlaywrightError, browser: str) -> str:
|
|
131
|
+
"""Turn a browser start-up error into a message that says what to do.
|
|
132
|
+
|
|
133
|
+
:param error: Error from Playwright.
|
|
134
|
+
:param browser: The browser that failed to start.
|
|
135
|
+
:return: A short message for the user.
|
|
136
|
+
"""
|
|
137
|
+
message = str(error)
|
|
138
|
+
if "ProcessSingleton" in message:
|
|
139
|
+
return (
|
|
140
|
+
"Another idp2aws login is using the browser profile. "
|
|
141
|
+
"Finish or close it, then try again."
|
|
142
|
+
)
|
|
143
|
+
if "is not found" in message or "Executable doesn't exist" in message:
|
|
144
|
+
return f"Cannot find {browser}. Install it, or set a different browser."
|
|
145
|
+
return f"Cannot start {browser}: {message.splitlines()[0]}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _has_several_roles(saml_response: str) -> bool:
|
|
149
|
+
"""Tell whether the SAML response offers more than one role.
|
|
150
|
+
|
|
151
|
+
:param saml_response: The base64 ``SAMLResponse`` value.
|
|
152
|
+
:return: ``False`` if it lists one role or cannot be read. The caller
|
|
153
|
+
reports an unreadable response.
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
return len(saml.parse(saml_response).roles) > 1
|
|
157
|
+
except saml.SAMLError:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _wait_until(
|
|
162
|
+
is_done: Callable[[], bool], context: BrowserContext, timeout_secs: int
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Let the browser run until ``is_done`` returns ``True``.
|
|
165
|
+
|
|
166
|
+
Playwright only handles browser events while we call into it, so we wait
|
|
167
|
+
in short steps on the page instead of sleeping.
|
|
168
|
+
|
|
169
|
+
:param is_done: Check that ends the wait.
|
|
170
|
+
:param context: The open browser.
|
|
171
|
+
:param timeout_secs: How long to wait.
|
|
172
|
+
:raises LoginError: If the window is closed or the time runs out.
|
|
173
|
+
"""
|
|
174
|
+
deadline = time.monotonic() + timeout_secs
|
|
175
|
+
while not is_done():
|
|
176
|
+
if not context.pages:
|
|
177
|
+
raise LoginError("The browser window was closed before the login finished.")
|
|
178
|
+
if time.monotonic() > deadline:
|
|
179
|
+
raise LoginError(f"The login did not finish within {timeout_secs} seconds.")
|
|
180
|
+
try:
|
|
181
|
+
context.pages[0].wait_for_timeout(250)
|
|
182
|
+
except PlaywrightError:
|
|
183
|
+
pass # The page closed during the wait. The next loop checks again.
|
idp2aws/cli.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Module for the ``idp2aws`` command line.
|
|
2
|
+
|
|
3
|
+
Messages for you go to stderr, so stdout stays clean for ``login --json``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import webbrowser
|
|
8
|
+
from typing import Annotated, NoReturn
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from botocore.exceptions import ClientError
|
|
12
|
+
|
|
13
|
+
from idp2aws import __version__, aws, browser, saml, settings
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
help="Get short-lived AWS keys. You sign in to your IdP in a browser.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
add_completion=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
ProfileArgument = Annotated[
|
|
22
|
+
str, typer.Argument(help="AWS profile for the keys, for example dev.")
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def show_version(is_wanted: bool) -> None:
|
|
27
|
+
"""Print the idp2aws version and stop, for ``--version``.
|
|
28
|
+
|
|
29
|
+
:param is_wanted: ``True`` when ``--version`` was given.
|
|
30
|
+
:raises typer.Exit: After printing the version.
|
|
31
|
+
"""
|
|
32
|
+
if is_wanted:
|
|
33
|
+
typer.echo(f"idp2aws {__version__}")
|
|
34
|
+
raise typer.Exit()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.callback()
|
|
38
|
+
def main(
|
|
39
|
+
_version: Annotated[
|
|
40
|
+
bool,
|
|
41
|
+
typer.Option(
|
|
42
|
+
"--version", callback=show_version, is_eager=True, help="Show the version."
|
|
43
|
+
),
|
|
44
|
+
] = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Get short-lived AWS keys. You sign in to your IdP in a browser."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def setup(
|
|
51
|
+
start_url: Annotated[str, typer.Argument(help="Start URL of your IdP for AWS.")],
|
|
52
|
+
browser_name: Annotated[
|
|
53
|
+
settings.Browser, typer.Option("--browser")
|
|
54
|
+
] = settings.Browser.CHROME,
|
|
55
|
+
session_hours: Annotated[
|
|
56
|
+
int,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--hours",
|
|
59
|
+
min=1,
|
|
60
|
+
max=settings.MAX_SESSION_HOURS,
|
|
61
|
+
help="How long AWS keys last.",
|
|
62
|
+
),
|
|
63
|
+
] = 1,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Create the config file with settings shared by every profile."""
|
|
66
|
+
try:
|
|
67
|
+
shared = settings.Settings(start_url, browser_name.value, session_hours)
|
|
68
|
+
settings.create_config(shared)
|
|
69
|
+
except settings.SettingsError as error:
|
|
70
|
+
fail(str(error))
|
|
71
|
+
say(f"Created {settings.CONFIG_FILE}. Next: idp2aws login <profile>")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command()
|
|
75
|
+
def login(
|
|
76
|
+
profile: ProfileArgument,
|
|
77
|
+
pick: Annotated[
|
|
78
|
+
bool,
|
|
79
|
+
typer.Option("--pick", help="Show the AWS role page and pick a different role."),
|
|
80
|
+
] = False,
|
|
81
|
+
as_json: Annotated[
|
|
82
|
+
bool,
|
|
83
|
+
typer.Option("--json", help="Print the keys for credential_process."),
|
|
84
|
+
] = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Sign in and save AWS keys to a profile in ~/.aws/credentials.
|
|
87
|
+
|
|
88
|
+
The first login for a profile shows the AWS role page. The role you pick
|
|
89
|
+
is used again next time.
|
|
90
|
+
"""
|
|
91
|
+
credentials = get_credentials(profile, pick)
|
|
92
|
+
if as_json:
|
|
93
|
+
typer.echo(credentials.as_json())
|
|
94
|
+
return
|
|
95
|
+
expires = credentials.expiration.astimezone()
|
|
96
|
+
say(f"Keys for profile {profile!r} work until {expires:%H:%M on %d %b}.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def run(
|
|
101
|
+
profile: ProfileArgument,
|
|
102
|
+
command: Annotated[
|
|
103
|
+
list[str], typer.Argument(help="The command to run. Put it after --.")
|
|
104
|
+
],
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Run a command with the profile's AWS keys in its environment.
|
|
107
|
+
|
|
108
|
+
Example: idp2aws run dev -- aws s3 ls
|
|
109
|
+
"""
|
|
110
|
+
credentials = get_credentials(profile)
|
|
111
|
+
env = {key: value for key, value in os.environ.items() if key != "AWS_PROFILE"}
|
|
112
|
+
try:
|
|
113
|
+
os.execvpe(command[0], command, env | credentials.as_env())
|
|
114
|
+
except FileNotFoundError:
|
|
115
|
+
fail(f"Command not found: {command[0]}")
|
|
116
|
+
except OSError as error:
|
|
117
|
+
fail(f"Cannot run {command[0]}: {error.strerror}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command()
|
|
121
|
+
def console(profile: ProfileArgument) -> None:
|
|
122
|
+
"""Open the AWS console for the profile in your web browser."""
|
|
123
|
+
credentials = get_credentials(profile)
|
|
124
|
+
role_arn = settings.load_profiles()[profile].role_arn
|
|
125
|
+
if not role_arn.startswith("arn:aws:"):
|
|
126
|
+
fail("The console link only works for commercial AWS, not GovCloud or China.")
|
|
127
|
+
try:
|
|
128
|
+
webbrowser.open(aws.get_console_url(credentials))
|
|
129
|
+
except OSError as error:
|
|
130
|
+
fail(f"Cannot reach the AWS sign-in service: {error}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def profiles() -> None:
|
|
135
|
+
"""List your profiles, their roles and when their keys expire."""
|
|
136
|
+
try:
|
|
137
|
+
configured = settings.configured_profiles()
|
|
138
|
+
except settings.SettingsError as error:
|
|
139
|
+
fail(str(error))
|
|
140
|
+
saved = settings.load_profiles()
|
|
141
|
+
for name in sorted(set(configured) | set(saved)):
|
|
142
|
+
if name not in saved:
|
|
143
|
+
typer.echo(f"{name:<20} not logged in yet")
|
|
144
|
+
continue
|
|
145
|
+
expires = saved[name].expires.astimezone()
|
|
146
|
+
typer.echo(f"{name:<20} {saved[name].role_arn} until {expires:%H:%M on %d %b}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_credentials(profile: str, pick: bool = False) -> aws.Credentials:
|
|
150
|
+
"""Get working keys for a profile, signing in only if the saved ones expired.
|
|
151
|
+
|
|
152
|
+
The role comes from the profile's ``role_arn`` setting, then from your
|
|
153
|
+
last pick. With neither, or with ``pick``, the AWS role page is shown.
|
|
154
|
+
|
|
155
|
+
:param profile: AWS profile to save the keys to.
|
|
156
|
+
:param pick: Sign in again and show the AWS role page.
|
|
157
|
+
:return: Keys that have not expired.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
config = settings.load_settings(profile)
|
|
161
|
+
except settings.SettingsError as error:
|
|
162
|
+
fail(str(error))
|
|
163
|
+
saved = settings.load_profiles().get(profile)
|
|
164
|
+
if saved and not pick:
|
|
165
|
+
credentials = aws.load_credentials(profile, saved.expires)
|
|
166
|
+
if credentials and not credentials.is_expired:
|
|
167
|
+
return credentials
|
|
168
|
+
|
|
169
|
+
role_arn = config.role_arn or (saved.role_arn if saved else None)
|
|
170
|
+
if pick:
|
|
171
|
+
role_arn = None
|
|
172
|
+
if role_arn and role_arn == config.role_arn:
|
|
173
|
+
fix = f"Change role_arn for {profile} in {settings.CONFIG_FILE}."
|
|
174
|
+
else:
|
|
175
|
+
fix = "Log in again with --pick."
|
|
176
|
+
say(f"Opening the browser to sign in for {profile}...")
|
|
177
|
+
try:
|
|
178
|
+
login_result = browser.log_in(config.start_url, config.browser, role_arn)
|
|
179
|
+
assertion = saml.parse(login_result.saml_response)
|
|
180
|
+
role = choose_role(assertion, login_result.picked_role_arn or role_arn, fix)
|
|
181
|
+
say(f"Using role {role.role_arn}")
|
|
182
|
+
credentials = aws.assume_role(
|
|
183
|
+
role,
|
|
184
|
+
assertion,
|
|
185
|
+
config.session_hours * 3600,
|
|
186
|
+
aws.get_sts_region(assertion.destination),
|
|
187
|
+
)
|
|
188
|
+
except (browser.LoginError, saml.SAMLError) as error:
|
|
189
|
+
fail(str(error))
|
|
190
|
+
except ClientError as error:
|
|
191
|
+
fail(explain_sts_error(error))
|
|
192
|
+
|
|
193
|
+
aws.save_credentials(profile, credentials)
|
|
194
|
+
remembered = settings.Profile(profile, role.role_arn, credentials.expiration)
|
|
195
|
+
settings.save_profile(remembered)
|
|
196
|
+
return credentials
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def choose_role(
|
|
200
|
+
assertion: saml.Assertion, role_arn: str | None, fix: str = ""
|
|
201
|
+
) -> saml.Role:
|
|
202
|
+
"""Find the role to use in the SAML response.
|
|
203
|
+
|
|
204
|
+
:param assertion: The SAML response.
|
|
205
|
+
:param role_arn: Role picked on the AWS page, set in the config file or
|
|
206
|
+
remembered. If ``None``, the response must list exactly one role.
|
|
207
|
+
:param fix: What to do if ``role_arn`` is not in the response. It depends
|
|
208
|
+
on where the role came from.
|
|
209
|
+
:return: The matching role.
|
|
210
|
+
"""
|
|
211
|
+
roles = assertion.roles
|
|
212
|
+
if not roles:
|
|
213
|
+
fail("The SAML response lists no AWS roles for you. Ask your IdP admin.")
|
|
214
|
+
if role_arn is None:
|
|
215
|
+
if len(roles) > 1:
|
|
216
|
+
fail("You did not pick a role. Try again and click Sign In on the AWS page.")
|
|
217
|
+
return roles[0]
|
|
218
|
+
role = next((role for role in roles if role.role_arn == role_arn), None)
|
|
219
|
+
if role is None:
|
|
220
|
+
fail(f"You can no longer use {role_arn}. {fix}".strip())
|
|
221
|
+
return role
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def explain_sts_error(error: ClientError) -> str:
|
|
225
|
+
"""Turn an STS error into a message that says what to do about it.
|
|
226
|
+
|
|
227
|
+
:param error: Error raised by ``AssumeRoleWithSAML``.
|
|
228
|
+
:return: The STS message, with a hint when we know the usual fix.
|
|
229
|
+
"""
|
|
230
|
+
message = error.response["Error"]["Message"]
|
|
231
|
+
if "DurationSeconds" in message:
|
|
232
|
+
return (
|
|
233
|
+
f"{message}\nMake session_hours smaller, or ask an AWS admin to increase "
|
|
234
|
+
"the maximum session duration of the role."
|
|
235
|
+
)
|
|
236
|
+
if "expired" in message.lower() or "NotOnOrAfter" in message:
|
|
237
|
+
return f"{message}\nYour computer clock may be wrong. Fix it and try again."
|
|
238
|
+
return message
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def say(message: str) -> None:
|
|
242
|
+
"""Print a message for the user on stderr.
|
|
243
|
+
|
|
244
|
+
:param message: Text to print.
|
|
245
|
+
"""
|
|
246
|
+
typer.echo(message, err=True)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def fail(message: str) -> NoReturn:
|
|
250
|
+
"""Print an error and exit with status 1.
|
|
251
|
+
|
|
252
|
+
:param message: What went wrong and, if known, what to do.
|
|
253
|
+
:raises typer.Exit: Always.
|
|
254
|
+
"""
|
|
255
|
+
say(f"Error: {message}")
|
|
256
|
+
raise typer.Exit(1)
|
idp2aws/files.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Module for writing files so that readers never see half a file."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def write_file(path: Path, text: str, mode: int = 0o644) -> None:
|
|
9
|
+
"""Replace a file in one step.
|
|
10
|
+
|
|
11
|
+
The text goes to a temporary file in the same folder first, then that file
|
|
12
|
+
replaces the target. If two runs write at the same time, the file holds
|
|
13
|
+
one of the two versions, never a mix. The target always gets ``mode``.
|
|
14
|
+
|
|
15
|
+
:param path: File to write. If it is a symlink, the file it points to is
|
|
16
|
+
replaced.
|
|
17
|
+
:param text: Full new content of the file.
|
|
18
|
+
:param mode: Permissions of the new file.
|
|
19
|
+
"""
|
|
20
|
+
path = path.resolve()
|
|
21
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
22
|
+
handle, temp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
|
|
23
|
+
try:
|
|
24
|
+
with os.fdopen(handle, "w", encoding="utf-8") as file:
|
|
25
|
+
file.write(text)
|
|
26
|
+
os.chmod(temp_name, mode)
|
|
27
|
+
os.replace(temp_name, path)
|
|
28
|
+
except BaseException:
|
|
29
|
+
os.unlink(temp_name)
|
|
30
|
+
raise
|
idp2aws/saml.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Module for reading the parts of a SAML response that AWS cares about.
|
|
2
|
+
|
|
3
|
+
The IdP signs the response and AWS STS checks the signature, so here we
|
|
4
|
+
only read it: the roles you may use, and the AWS address it is meant for.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import binascii
|
|
9
|
+
import re
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
ROLE_ATTRIBUTE = "https://aws.amazon.com/SAML/Attributes/Role"
|
|
14
|
+
ARN = re.compile(r"arn:[\w-]+:iam::\d{12}:[^,\s]+")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SAMLError(Exception):
|
|
18
|
+
"""Raise when a SAML response or one of its roles cannot be read."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Role:
|
|
23
|
+
"""An AWS role listed in the SAML response.
|
|
24
|
+
|
|
25
|
+
:param role_arn: The IAM role you may use.
|
|
26
|
+
:param principal_arn: The IAM SAML provider that trusts your IdP.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
role_arn: str
|
|
30
|
+
principal_arn: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Assertion:
|
|
35
|
+
"""A SAML response with the parts we need read out.
|
|
36
|
+
|
|
37
|
+
:param raw: The base64 text exactly as the IdP sent it. STS wants this.
|
|
38
|
+
:param roles: Roles you may use, in the order the IdP listed them.
|
|
39
|
+
:param destination: AWS sign-in address the response is meant for.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
raw: str
|
|
43
|
+
roles: list[Role]
|
|
44
|
+
destination: str | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse(saml_response: str) -> Assertion:
|
|
48
|
+
"""Read a base64 SAML response.
|
|
49
|
+
|
|
50
|
+
:param saml_response: The ``SAMLResponse`` form value.
|
|
51
|
+
:return: The roles and destination in the response.
|
|
52
|
+
:raises SAMLError: If it is not base64 XML, or a role cannot be read.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
root = ET.fromstring(base64.b64decode(saml_response))
|
|
56
|
+
except (binascii.Error, ET.ParseError) as error:
|
|
57
|
+
raise SAMLError(f"The SAML response is not base64 XML: {error}") from None
|
|
58
|
+
|
|
59
|
+
roles = []
|
|
60
|
+
for attribute in root.iterfind(".//{*}Attribute"):
|
|
61
|
+
if attribute.get("Name") == ROLE_ATTRIBUTE:
|
|
62
|
+
roles += [parse_role(value.text or "") for value in attribute]
|
|
63
|
+
|
|
64
|
+
destination = root.get("Destination")
|
|
65
|
+
if destination is None:
|
|
66
|
+
confirmation = root.find(".//{*}SubjectConfirmationData")
|
|
67
|
+
destination = confirmation.get("Recipient") if confirmation is not None else None
|
|
68
|
+
|
|
69
|
+
return Assertion(raw=saml_response, roles=roles, destination=destination)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_role(value: str) -> Role:
|
|
73
|
+
"""Split one role value into its two ARNs.
|
|
74
|
+
|
|
75
|
+
The value should be ``<role ARN>,<provider ARN>``, but some IdPs swap
|
|
76
|
+
them, so we tell them apart by what they contain.
|
|
77
|
+
|
|
78
|
+
:param value: Text of one ``AttributeValue`` in the role attribute.
|
|
79
|
+
:return: The role and its SAML provider.
|
|
80
|
+
:raises SAMLError: If the value does not hold exactly those two ARNs.
|
|
81
|
+
"""
|
|
82
|
+
arns = ARN.findall(value)
|
|
83
|
+
role = next((arn for arn in arns if ":role/" in arn), None)
|
|
84
|
+
principal = next((arn for arn in arns if ":saml-provider/" in arn), None)
|
|
85
|
+
if len(arns) != 2 or role is None or principal is None:
|
|
86
|
+
raise SAMLError(f"Cannot read this role value: {value!r}")
|
|
87
|
+
return Role(role_arn=role, principal_arn=principal)
|
idp2aws/settings.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Module for the config file and the profiles idp2aws remembers.
|
|
2
|
+
|
|
3
|
+
Both files live in ``~/.idp2aws``, or in ``$IDP2AWS_HOME`` if it is set.
|
|
4
|
+
|
|
5
|
+
``config.toml`` is yours to edit. Settings at the top apply to every
|
|
6
|
+
profile, and a ``[profiles.<name>]`` table overrides them for one profile::
|
|
7
|
+
|
|
8
|
+
start_url = "https://accounts.google.com/o/saml2/initsso?idpid=X&spid=Y"
|
|
9
|
+
session_hours = 1
|
|
10
|
+
|
|
11
|
+
[profiles.prod]
|
|
12
|
+
role_arn = "arn:aws:iam::123456789012:role/Developer"
|
|
13
|
+
session_hours = 4
|
|
14
|
+
|
|
15
|
+
``profiles.json`` is written by idp2aws: the role you last picked for each
|
|
16
|
+
profile, and when its keys expire.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import tomllib
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from idp2aws.files import write_file
|
|
28
|
+
|
|
29
|
+
HOME = Path(os.environ.get("IDP2AWS_HOME", Path.home() / ".idp2aws"))
|
|
30
|
+
CONFIG_FILE = HOME / "config.toml"
|
|
31
|
+
PROFILES_FILE = HOME / "profiles.json"
|
|
32
|
+
MAX_SESSION_HOURS = 12
|
|
33
|
+
|
|
34
|
+
CONFIG_TEMPLATE = """\
|
|
35
|
+
# Settings for every profile.
|
|
36
|
+
start_url = {start_url}
|
|
37
|
+
browser = "{browser}"
|
|
38
|
+
session_hours = {session_hours}
|
|
39
|
+
|
|
40
|
+
# Settings for one profile go in its own table and win over the ones above.
|
|
41
|
+
# [profiles.prod]
|
|
42
|
+
# role_arn = "arn:aws:iam::123456789012:role/Developer"
|
|
43
|
+
# session_hours = 4
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SettingsError(Exception):
|
|
48
|
+
"""Raise when the config file is missing, cannot be read or has a bad value."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Browser(str, Enum):
|
|
52
|
+
"""Browsers idp2aws can open."""
|
|
53
|
+
|
|
54
|
+
CHROME = "chrome"
|
|
55
|
+
CHROMIUM = "chromium"
|
|
56
|
+
MSEDGE = "msedge"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Settings:
|
|
61
|
+
"""Settings for one login, after profile overrides are applied.
|
|
62
|
+
|
|
63
|
+
:param start_url: Start URL of your IdP for AWS.
|
|
64
|
+
:param browser: ``chrome`` or ``msedge`` for your installed Chrome or Edge,
|
|
65
|
+
or ``chromium`` for the one Playwright downloads.
|
|
66
|
+
:param session_hours: How long the AWS keys last.
|
|
67
|
+
:param role_arn: Role to use without showing the AWS role page.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
start_url: str
|
|
71
|
+
browser: str = "chrome"
|
|
72
|
+
session_hours: int = 1
|
|
73
|
+
role_arn: str | None = None
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
"""Check the values, because the config file is edited by hand.
|
|
77
|
+
|
|
78
|
+
:raises SettingsError: If a value has the wrong type or is out of range.
|
|
79
|
+
"""
|
|
80
|
+
url = self.start_url
|
|
81
|
+
if not isinstance(url, str) or not url.startswith("https://"):
|
|
82
|
+
raise SettingsError(f"start_url must be an https:// URL, not {url!r}.")
|
|
83
|
+
choices = [browser.value for browser in Browser]
|
|
84
|
+
if self.browser not in choices:
|
|
85
|
+
raise SettingsError(
|
|
86
|
+
f"browser must be one of {', '.join(choices)}, not {self.browser!r}."
|
|
87
|
+
)
|
|
88
|
+
hours = self.session_hours
|
|
89
|
+
is_whole = isinstance(hours, int) and not isinstance(hours, bool)
|
|
90
|
+
if not is_whole or not 1 <= hours <= MAX_SESSION_HOURS:
|
|
91
|
+
raise SettingsError(
|
|
92
|
+
f"session_hours must be a whole number from 1 to {MAX_SESSION_HOURS},"
|
|
93
|
+
f" not {hours!r}."
|
|
94
|
+
)
|
|
95
|
+
if self.role_arn is not None and not isinstance(self.role_arn, str):
|
|
96
|
+
raise SettingsError(f"role_arn must be text, not {self.role_arn!r}.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Profile:
|
|
101
|
+
"""An AWS profile that idp2aws has saved keys to.
|
|
102
|
+
|
|
103
|
+
:param name: Profile name in the AWS credentials file.
|
|
104
|
+
:param role_arn: Role the keys belong to. The next login uses it again.
|
|
105
|
+
:param expires: When the keys stop working.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
name: str
|
|
109
|
+
role_arn: str
|
|
110
|
+
expires: datetime
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_settings(profile: str, path: Path = CONFIG_FILE) -> Settings:
|
|
114
|
+
"""Read the settings for one profile from the config file.
|
|
115
|
+
|
|
116
|
+
:param profile: Profile whose table, if any, overrides the shared settings.
|
|
117
|
+
:param path: Config file to read.
|
|
118
|
+
:return: The shared settings with the profile's overrides applied.
|
|
119
|
+
:raises SettingsError: If the file is missing, is not valid TOML, or has
|
|
120
|
+
an unknown or missing key.
|
|
121
|
+
"""
|
|
122
|
+
config = _read_config(path)
|
|
123
|
+
overrides = config.pop("profiles", {}).get(profile, {})
|
|
124
|
+
try:
|
|
125
|
+
return Settings(**config | overrides)
|
|
126
|
+
except TypeError as error:
|
|
127
|
+
message = f"{path} has an unknown or missing setting: {error}"
|
|
128
|
+
raise SettingsError(message) from None
|
|
129
|
+
except SettingsError as error:
|
|
130
|
+
raise SettingsError(f"{path}: {error}") from None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def configured_profiles(path: Path = CONFIG_FILE) -> list[str]:
|
|
134
|
+
"""List the profiles that have a table in the config file.
|
|
135
|
+
|
|
136
|
+
:param path: Config file to read.
|
|
137
|
+
:return: Profile names, sorted.
|
|
138
|
+
:raises SettingsError: If the file is missing or is not valid TOML.
|
|
139
|
+
"""
|
|
140
|
+
return sorted(_read_config(path).get("profiles", {}))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def create_config(settings: Settings, path: Path = CONFIG_FILE) -> None:
|
|
144
|
+
"""Write a new config file with the shared settings.
|
|
145
|
+
|
|
146
|
+
:param settings: Shared settings to write.
|
|
147
|
+
:param path: Config file to create.
|
|
148
|
+
:raises SettingsError: If the file already exists.
|
|
149
|
+
"""
|
|
150
|
+
if path.exists():
|
|
151
|
+
raise SettingsError(f"{path} already exists. To change it, edit the file.")
|
|
152
|
+
write_file(path, CONFIG_TEMPLATE.format(
|
|
153
|
+
start_url=json.dumps(settings.start_url),
|
|
154
|
+
browser=settings.browser,
|
|
155
|
+
session_hours=settings.session_hours,
|
|
156
|
+
))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_profiles(path: Path = PROFILES_FILE) -> dict[str, Profile]:
|
|
160
|
+
"""Read the profiles idp2aws has saved keys to.
|
|
161
|
+
|
|
162
|
+
:param path: Profiles file to read.
|
|
163
|
+
:return: Profiles by name. Empty if there are none yet.
|
|
164
|
+
"""
|
|
165
|
+
if not path.exists():
|
|
166
|
+
return {}
|
|
167
|
+
return {
|
|
168
|
+
name: Profile(name, item["role_arn"], datetime.fromisoformat(item["expires"]))
|
|
169
|
+
for name, item in json.loads(path.read_text()).items()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def save_profile(profile: Profile, path: Path = PROFILES_FILE) -> None:
|
|
174
|
+
"""Remember a profile, keeping the others.
|
|
175
|
+
|
|
176
|
+
:param profile: Profile to add or replace.
|
|
177
|
+
:param path: Profiles file to write.
|
|
178
|
+
"""
|
|
179
|
+
profiles = load_profiles(path) | {profile.name: profile}
|
|
180
|
+
data = {
|
|
181
|
+
name: {"role_arn": item.role_arn, "expires": item.expires.isoformat()}
|
|
182
|
+
for name, item in sorted(profiles.items())
|
|
183
|
+
}
|
|
184
|
+
write_file(path, json.dumps(data, indent=2) + "\n")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _read_config(path: Path) -> dict:
|
|
188
|
+
"""Read the config file as a dict.
|
|
189
|
+
|
|
190
|
+
:param path: Config file to read.
|
|
191
|
+
:return: The parsed TOML.
|
|
192
|
+
:raises SettingsError: If the file is missing or is not valid TOML.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
with path.open("rb") as file:
|
|
196
|
+
return tomllib.load(file)
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
raise SettingsError(
|
|
199
|
+
"idp2aws is not set up yet. Run: idp2aws setup <start-url>"
|
|
200
|
+
) from None
|
|
201
|
+
except tomllib.TOMLDecodeError as error:
|
|
202
|
+
raise SettingsError(f"{path} is not valid TOML: {error}") from None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: idp2aws
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Get short-lived AWS keys by signing in to your SAML IdP in a real browser
|
|
5
|
+
Project-URL: Homepage, https://github.com/saimoncse19/idp2aws
|
|
6
|
+
Project-URL: Documentation, https://github.com/saimoncse19/idp2aws/blob/main/docs/USAGE.md
|
|
7
|
+
Project-URL: Issues, https://github.com/saimoncse19/idp2aws/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/saimoncse19/idp2aws/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Saimon
|
|
10
|
+
License-Expression: LicenseRef-MIT-Commons-Clause
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: aws,cli,entra-id,google-workspace,okta,saml,sso,sts
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: System Administrators
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
19
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Requires-Python: >=3.12
|
|
27
|
+
Requires-Dist: boto3>=1.34
|
|
28
|
+
Requires-Dist: playwright>=1.44
|
|
29
|
+
Requires-Dist: typer>=0.12
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: build>=1; extra == 'dev'
|
|
32
|
+
Requires-Dist: pylint>=3; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-mock>=3; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
35
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# idp2aws
|
|
39
|
+
|
|
40
|
+
idp2aws gets short-lived AWS keys for you. You sign in to your IdP in a real browser window.
|
|
41
|
+
It works with Google Workspace, Okta, Entra ID and other SAML IdPs that sign you in to AWS
|
|
42
|
+
accounts directly.
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
idp2aws setup 'https://accounts.google.com/o/saml2/initsso?idpid=XXXX&spid=YYYY'
|
|
46
|
+
idp2aws login dev # pick a role, idp2aws saves the keys to profile "dev"
|
|
47
|
+
aws --profile dev s3 ls # use the keys
|
|
48
|
+
idp2aws run dev -- terraform plan # run one command with the keys
|
|
49
|
+
idp2aws console dev # open the AWS console
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The first login for a profile shows the AWS role page, sorted by account name. idp2aws keeps
|
|
53
|
+
the role that you pick. The next logins for that profile use this role and do not show the page.
|
|
54
|
+
While your IdP session is active, the browser window opens and closes without input from you.
|
|
55
|
+
|
|
56
|
+
idp2aws does not work with AWS IAM Identity Center. For Identity Center, use `aws sso login`.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
idp2aws needs Python 3.12 or newer, and Google Chrome or Microsoft Edge.
|
|
61
|
+
|
|
62
|
+
Install from PyPI with pipx or pip:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
pipx install idp2aws
|
|
66
|
+
pip install idp2aws # if you manage the virtual environment yourself
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or install from source:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
pipx install git+https://github.com/saimoncse19/idp2aws.git # latest code on main
|
|
73
|
+
|
|
74
|
+
git clone https://github.com/saimoncse19/idp2aws.git && cd idp2aws # to change the code
|
|
75
|
+
python3.12 -m venv .venv
|
|
76
|
+
.venv/bin/pip install -e .
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
For step-by-step install on macOS, Linux and Windows, the config file and troubleshooting, see
|
|
80
|
+
[docs/USAGE.md](https://github.com/saimoncse19/idp2aws/blob/main/docs/USAGE.md).
|
|
81
|
+
|
|
82
|
+
## How it works
|
|
83
|
+
|
|
84
|
+
1. Chrome opens at the start URL. You sign in to your IdP with your usual 2-Step Verification.
|
|
85
|
+
2. The IdP sends a signed SAML response to the AWS sign-in page.
|
|
86
|
+
3. If you have more than one role and the profile has no role yet, idp2aws lets AWS show its
|
|
87
|
+
role page. You pick a role and click Sign In.
|
|
88
|
+
4. idp2aws stops the SAML response before it gets to AWS, and closes the window.
|
|
89
|
+
5. idp2aws calls STS `AssumeRoleWithSAML` with the SAML response and gets keys for the role.
|
|
90
|
+
6. idp2aws writes the keys to the profile in `~/.aws/credentials`.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
git clone https://github.com/saimoncse19/idp2aws.git && cd idp2aws
|
|
96
|
+
python3.12 -m venv .venv
|
|
97
|
+
source .venv/bin/activate
|
|
98
|
+
pip install -e '.[dev]'
|
|
99
|
+
pytest # unit tests
|
|
100
|
+
pytest -m browser # browser tests: headless Chrome with a fake IdP and AWS role page
|
|
101
|
+
pylint src scripts
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The `CI` workflow runs the same checks on each pull request, and also builds the packages.
|
|
105
|
+
|
|
106
|
+
## Release
|
|
107
|
+
|
|
108
|
+
idp2aws uses semantic versioning: `MAJOR.MINOR.PATCH`. Each release must be the next patch,
|
|
109
|
+
minor or major version after the latest one on PyPI. After 1.4.2, the only choices are 1.4.3,
|
|
110
|
+
1.5.0 and 2.0.0.
|
|
111
|
+
|
|
112
|
+
1. Set `__version__` in `src/idp2aws/__init__.py`. This is the only place the version is set.
|
|
113
|
+
2. Add the version and the date to `CHANGELOG.md`.
|
|
114
|
+
3. Merge to `main`.
|
|
115
|
+
4. On GitHub, create a release with the tag `v` plus the version, for example `v1.4.3`.
|
|
116
|
+
|
|
117
|
+
The `Publish to PyPI` workflow then runs the tests, checks the version and uploads to PyPI. If
|
|
118
|
+
the tag does not match `__version__`, or the version skips or repeats one, the workflow stops
|
|
119
|
+
and uploads nothing. To check a version before you release, run
|
|
120
|
+
`python scripts/check_version.py v1.4.3`.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
idp2aws is source-available under the MIT license with the
|
|
125
|
+
[Commons Clause](https://commonsclause.com/). You can use, change and share it for free,
|
|
126
|
+
including at work. You cannot sell it, or sell a product or service whose value comes mainly
|
|
127
|
+
from it. See [LICENSE](https://github.com/saimoncse19/idp2aws/blob/main/LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
idp2aws/__init__.py,sha256=3S3mDGZBzj87MBRKQ04w6aiZgU3uumlXacBur-v4zhA,108
|
|
2
|
+
idp2aws/aws.py,sha256=Bk7Wrb-FJkCdTlvn4YPieMeucvCxcCbwqJIiDvnZwTU,6811
|
|
3
|
+
idp2aws/browser.py,sha256=pB47pqvZs60tULVHkY2_Lzk_0K7TRmIyYyx5vHR8i6w,7086
|
|
4
|
+
idp2aws/cli.py,sha256=mvY9mSHSDBRtT2N17RsCp7kZ1gUW2wg8W_IRIsMTvrY,8560
|
|
5
|
+
idp2aws/files.py,sha256=CNRy-jNntxvNS6vOrpCoYCCQA4Gk0EUKI0HTgHRUn7w,1065
|
|
6
|
+
idp2aws/saml.py,sha256=I_UcWYxpSl_OsO7wiEjsI9YCYIs_Kic215-z46jRv0s,2944
|
|
7
|
+
idp2aws/settings.py,sha256=vXwZKTXMeBMIJzwzVuhD3P6y4NAtCcU090ZBgYCnBSc,6754
|
|
8
|
+
idp2aws-0.2.0.dist-info/METADATA,sha256=94VmyWIv8Furql-ecx78G32UA3rrBBgPoBVm_p2H7UQ,5275
|
|
9
|
+
idp2aws-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
idp2aws-0.2.0.dist-info/entry_points.txt,sha256=bq5VCCB49SHz9rE0WAHL6yGXhzLNMAQZFLRd9aODeFo,44
|
|
11
|
+
idp2aws-0.2.0.dist-info/licenses/LICENSE,sha256=NV3K4GXQfebKwPG1-RWs8T48LC4u9BfUXZSsSSCXXsw,2046
|
|
12
|
+
idp2aws-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"Commons Clause" License Condition v1.0
|
|
2
|
+
|
|
3
|
+
The Software is provided to you by the Licensor under the License, as defined
|
|
4
|
+
below, subject to the following condition.
|
|
5
|
+
|
|
6
|
+
Without limiting other conditions in the License, the grant of rights under the
|
|
7
|
+
License will not include, and the License does not grant to you, the right to
|
|
8
|
+
Sell the Software.
|
|
9
|
+
|
|
10
|
+
For purposes of the foregoing, "Sell" means practicing any or all of the rights
|
|
11
|
+
granted to you under the License to provide to third parties, for a fee or other
|
|
12
|
+
consideration (including without limitation fees for hosting or consulting/
|
|
13
|
+
support services related to the Software), a product or service whose value
|
|
14
|
+
derives, entirely or substantially, from the functionality of the Software. Any
|
|
15
|
+
license notice or attribution required by the License must also include this
|
|
16
|
+
Commons Clause License Condition notice.
|
|
17
|
+
|
|
18
|
+
Software: idp2aws
|
|
19
|
+
License: MIT
|
|
20
|
+
Licensor: Saimon
|
|
21
|
+
|
|
22
|
+
-------------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
MIT License
|
|
25
|
+
|
|
26
|
+
Copyright (c) 2026 Saimon
|
|
27
|
+
|
|
28
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
29
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
30
|
+
in the Software without restriction, including without limitation the rights
|
|
31
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
32
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
33
|
+
furnished to do so, subject to the following conditions:
|
|
34
|
+
|
|
35
|
+
The above copyright notice and this permission notice shall be included in all
|
|
36
|
+
copies or substantial portions of the Software.
|
|
37
|
+
|
|
38
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
39
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
40
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
41
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
42
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
43
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
44
|
+
SOFTWARE.
|