[V2] Add the OAuth Authorization Code Flow with PKCE - #8947
Conversation
| @@ -0,0 +1,176 @@ | |||
| <!DOCTYPE html> | |||
There was a problem hiding this comment.
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.
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.
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.
| 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.
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.
nateprewitt
left a comment
There was a problem hiding this comment.
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.
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.
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
hssyoo
left a comment
There was a problem hiding this comment.
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.
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.
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.
nit: You can wrap text in double backticks (``) to render in RST as inline code. Here, let's wrap sso and aws sso login.
| 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.
| 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.
Done via e306e3e (didn't accept suggestion on GitHub since we had to rename in multiple places).
| 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.
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.
| if scopes: | ||
| register_kwargs['scopes'] = scopes | ||
| else: | ||
| register_kwargs['scopes'] = [self._AUTH_GRANT_DEFAULT_SCOPE] |
There was a problem hiding this comment.
We can just add 'scopes': scopes or [self._AUTH_GRANT_DEFAULT_SCOPE] to register_kwargs
|
|
||
| query_params = parse_qs(urlparse(self.path).query) | ||
|
|
||
| if 'error' in query_params: |
There was a problem hiding this comment.
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.
We show it in the browser via:
aws-cli/awscli/customizations/sso/index.html
Lines 168 to 172 in e306e3e
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.
Non-blocking: I'm in favor of it being visible in console via debug logs.
| self.assertIsNone(captured_env.get('LD_LIBRARY_PATH')) | ||
|
|
||
|
|
||
| class TestAuthCodeFetcher(unittest.TestCase): |
There was a problem hiding this comment.
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.
| self.assertEqual(self.fetcher._auth_code, expected_code) | ||
| self.assertEqual(self.fetcher._state, expected_state) |
There was a problem hiding this comment.
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
| 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.
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.
6cbf403 to
0f50729
Compare
|
c96d05c addresses two issues found during internal testing:
Added assertions to existing unit tests for both cases. |
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 logincommand. It is the new default behavior, but users can fall back to the current, device code flow using the new--use-device-codeoption.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.