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 ADDED
@@ -0,0 +1,3 @@
1
+ """Package for getting short-lived AWS keys by signing in to an IdP in a browser."""
2
+
3
+ __version__ = "0.2.0"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ idp2aws = idp2aws.cli:app
@@ -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.