-
Notifications
You must be signed in to change notification settings - Fork 4.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[V2] Add the OAuth Authorization Code Flow with PKCE #8947
Conversation
@@ -0,0 +1,176 @@ | |||
<!DOCTYPE html> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For reviewers, I lifted this from the IDE integration.
I did not include some of the extra styles and fonts, since having everything in one file simplifies serving the resources from the local server. But let me know if you'd like the additional fonts/icons.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unrelated to the above from an internal discussion, we need to make sure that this file is included in the source distribution. DOes it need to be in manifest.in
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It shouldn't need to be since it's under the top-level package directory (awscli
). We can generate the sdist to double check. Also I don't think v2 will use a manifest.in
, the sdist file inclusions outside of the core package should be listed here.
awscli/botocore/utils.py
Outdated
def _extract_resolved_endpoint(self, params, **kwargs): | ||
"""Event handler for before-call that will extract the resolved endpoint | ||
for a given request without actually running it | ||
""" | ||
# This will contain any path and query params specific to | ||
# the operation/input, so extract just the scheme and hostname | ||
if params['url']: | ||
parsed = urlparse(params['url']) | ||
self._base_endpoint = f'{parsed.scheme}://{parsed.netloc}' | ||
|
||
# Return a tuple containing the "response" to short-circuit the request | ||
return botocore.awsrequest.AWSResponse(None, 200, {}, None), {} | ||
|
||
def _get_base_authorization_uri(self): | ||
"""Simulates an SSO-OIDC request so that we can extract the "base" | ||
endpoint for the current client to use for the un-modeled Authorize | ||
operation | ||
""" | ||
self._client.meta.events.register('before-call', | ||
self._extract_resolved_endpoint) | ||
self._client.register_client( | ||
clientName='temp', | ||
clientType='public' | ||
) | ||
self._client.meta.events.unregister('before-call', | ||
self._extract_resolved_endpoint) | ||
|
||
return self._base_endpoint |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For reviewers, this is so that we can resolve the "base" endpoint using the endpoint rules now that client.meta.endpoint_url
is not routed through the endpoint resolver.
It's a little awkward since we're half-running this request, but this is possible using botocore's current public interface.
A possibly cleaner alternative would be to expose client._resolve_endpoint_ruleset
publicly, or allow some other public instantiation of the SSO-OIDC endpoint resolver.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a mix of formatting nits and some functionality questions baked in here. Let me know if I can clarify anything.
|
||
if state != expected_state: | ||
raise AuthorizationCodeLoadError( | ||
error_msg='State parameter does not match expected value.' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be useful to include one or both of those? Or is this mechanism opaque to the end user? I'm just not sure what I'm supposed to do if I get this error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is opaque because it's generated internally. This is guarding against CSFR, in case an an attacker is able to hit the redirect endpoint with their authorization code, injecting the attackers credentials into the user's application/workflow.
Users shouldn't see it short of a bug, bit flip, or if something is hitting the redirect before the normal worfklow does.
Do you think it's worth adding something like "You can try the SSO login workflow again."?
VS Code as a reference: https://github.com/aws/aws-toolkit-vscode/blob/3b6197b2cfa8345a0775096f06e0983aec50102b/packages/core/src/auth/sso/server.ts#L34-L38
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks solid. I mainly had smaller comments on following the repo's conventions.
I also noticed that we didn't add tests for SSOTokenFetcherAuth
in tests/unit/botocore/test_utils.py
.
|
||
def test_expected_auth_code(self): | ||
expected_code = '1234' | ||
expected_state = '4567' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the client code uses uuid.uuid4()
(which returns a UUID
object) to assign the expected state, let's make sure the test reflects that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this one is okay. From the viewpoint of the AuthCodeFetcher
/OAuthCallbackHandler
, the state
is just a string that it gets from the query parameters.
It's over in SSOTokenFetcherAuth
that it generates the UUID, the compares the string that was roundtripped to the original value. Here it's important to handle UUID vs. string and where the original revision has the bug.
"category": "sso", | ||
"description": "Add support and default to the OAuth 2.0 Authorization Code Flow with PKCE for aws sso login." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: You can wrap text in double backticks (``) to render in RST as inline code. Here, let's wrap sso
and aws sso login
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done via e306e3e
awscli/customizations/sso/utils.py
Outdated
@@ -56,24 +69,38 @@ def _sso_json_dumps(obj): | |||
|
|||
def do_sso_login(session, sso_region, start_url, token_cache=None, | |||
on_pending_authorization=None, force_refresh=False, | |||
registration_scopes=None, session_name=None): | |||
registration_scopes=None, session_name=None, | |||
fallback_to_device_flow=False): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fallback_to_device_flow=False): | |
use_device_code=False): |
To me, fallback_to_device_flow
implies that it'll attempt to use PKCE and fallback to device auth if it fails but it looks like setting this to True
will always result in device auth being used. And in general I tend to prefer keeping the parameter name unless we're doing extra stuff under the hood.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done via e306e3e (didn't accept suggestion on GitHub since we had to rename in multiple places).
awscli/customizations/sso/utils.py
Outdated
query_params = parse_qs(urlparse(self.path).query) | ||
|
||
if 'error' in query_params: | ||
self._auth_code_fetcher._is_done = True |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
non-blocking: It feels a bit weird to explicitly set AuthCodeFetcher
internal attributes here. I wonder if it's worth exposing an AuthCodeFetcher
method (eg set_auth_code_state()
) that can be called here instead. Feel free to take it or leave it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done via e306e3e
awscli/botocore/utils.py
Outdated
if scopes: | ||
register_kwargs['scopes'] = scopes | ||
else: | ||
register_kwargs['scopes'] = [self._AUTH_GRANT_DEFAULT_SCOPE] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can just add 'scopes': scopes or [self._AUTH_GRANT_DEFAULT_SCOPE]
to register_kwargs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done via e306e3e
|
||
query_params = parse_qs(urlparse(self.path).query) | ||
|
||
if 'error' in query_params: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there anything useful we can extract from query_params['error']
for the error message when auth_code is None
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We show it in the browser via:
aws-cli/awscli/customizations/sso/index.html
Lines 168 to 172 in e306e3e
function showErrorMessage(errorText) { | |
document.getElementById('approved-auth').classList.add('hidden') | |
document.getElementById('denied-auth').classList.remove('hidden') | |
document.getElementById('errorMessage').innerText = errorText | |
} |
Open to repeating it in the console if you want? Might be useful for someone providing us debug logs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-blocking: I'm in favor of it being visible in console via debug logs.
@@ -205,3 +206,45 @@ def test_can_patch_env(self): | |||
os.environ) | |||
open_browser_with_original_ld_path('http://example.com') | |||
self.assertIsNone(captured_env.get('LD_LIBRARY_PATH')) | |||
|
|||
|
|||
class TestAuthCodeFetcher(unittest.TestCase): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally, we want to use pytest
and move away from unittest
unless we're adding a couple test cases to an existing test class/module. Since this is a new test class let's use pytest
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done via e306e3e
self.assertEqual(self.fetcher._auth_code, expected_code) | ||
self.assertEqual(self.fetcher._state, expected_state) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this assertion could be cleaner if we asserted against the returned values for self.fetcher.get_auth_code_and_state()
. To do this we could return early if _is_done
is true in AuthCodeFetcher
:
def get_auth_code_and_state(self):
"""Blocks until the expected redirect request with either the
authorization code/state or and error is handled
"""
if self._is_done:
return self._auth_code, self._state
while not self._is_done:
self.http_server.handle_request()
self.http_server.server_close()
return self._auth_code, self._state
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done via e306e3e
self.assertEqual(response.status, 200) | ||
self.assertEqual(self.fetcher._auth_code, None) | ||
self.assertEqual(self.fetcher._state, None) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be nice to also have unit tests for OAuthCallbackHandler
. At a minimum, we should mock return values in do_GET()
and assert that the self._auth_code_fetcher
attributes are set.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done via 6cbf403
6cbf403
to
0f50729
Compare
c96d05c addresses two issues found during internal testing:
Added assertions to existing unit tests for both cases. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🏆
This adds support for the OAuth2.0 authorization code flow with PKCE to the aws sso login command. It is the new default behavior, but users can fall back to the device code flow using the new --use-device-code option.
0fc2814
to
e0ee9eb
Compare
Issue #, if available: N/A
Description of changes:
This adds support for the OAuth2.0 authorization code flow with PKCE to the
aws sso login
command. It is the new default behavior, but users can fall back to the current, device code flow using the new--use-device-code
option.Auth code + PKCE is recommended for devices with browsers and addresses some phishing vectors that are inherent in the device code flow.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.