Skip to content

Commit

Permalink
Multi-auth trait support (boto#3233)
Browse files Browse the repository at this point in the history
* Multiauth

Adds support for the new multi-auth trait that will allow a service or operation to specify a list of compatible authentication types

---------

Co-authored-by: Nate Prewitt <[email protected]>
  • Loading branch information
2 people authored and hswong3i committed Aug 17, 2024
1 parent 7fdb763 commit 636b5c4
Show file tree
Hide file tree
Showing 14 changed files with 286 additions and 18 deletions.
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-signing-83434.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "feature",
"category": "signing",
"description": "Adds internal support for the new 'auth' trait to allow a priority list of auth types for a service or operation."
}
3 changes: 3 additions & 0 deletions botocore/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,9 @@ def compute_client_args(
client_config.disable_request_compression
),
client_context_params=client_config.client_context_params,
sigv4a_signing_region_set=(
client_config.sigv4a_signing_region_set
),
)
self._compute_retry_config(config_kwargs)
self._compute_connect_timeout(config_kwargs)
Expand Down
27 changes: 26 additions & 1 deletion botocore/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@
urlsplit,
urlunsplit,
)
from botocore.exceptions import NoAuthTokenError, NoCredentialsError
from botocore.exceptions import (
NoAuthTokenError,
NoCredentialsError,
UnknownSignatureVersionError,
UnsupportedSignatureVersionError,
)
from botocore.utils import (
is_valid_ipv6_endpoint_url,
normalize_url_path,
Expand Down Expand Up @@ -1132,6 +1137,19 @@ def add_auth(self, request):
request.headers['Authorization'] = auth_header


def resolve_auth_type(auth_trait):
for auth_type in auth_trait:
if auth_type == 'smithy.api#noAuth':
return AUTH_TYPE_TO_SIGNATURE_VERSION[auth_type]
elif auth_type in AUTH_TYPE_TO_SIGNATURE_VERSION:
signature_version = AUTH_TYPE_TO_SIGNATURE_VERSION[auth_type]
if signature_version in AUTH_TYPE_MAPS:
return signature_version
else:
raise UnknownSignatureVersionError(signature_version=auth_type)
raise UnsupportedSignatureVersionError(signature_version=auth_trait)


AUTH_TYPE_MAPS = {
'v2': SigV2Auth,
'v3': SigV3Auth,
Expand Down Expand Up @@ -1160,3 +1178,10 @@ def add_auth(self, request):
's3v4-query': S3SigV4QueryAuth,
}
)

AUTH_TYPE_TO_SIGNATURE_VERSION = {
'aws.auth#sigv4': 'v4',
'aws.auth#sigv4a': 'v4a',
'smithy.api#httpBearerAuth': 'bearer',
'smithy.api#noAuth': 'none',
}
18 changes: 12 additions & 6 deletions botocore/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from botocore import waiter, xform_name
from botocore.args import ClientArgsCreator
from botocore.auth import AUTH_TYPE_MAPS
from botocore.auth import AUTH_TYPE_MAPS, resolve_auth_type
from botocore.awsrequest import prepare_request_dict
from botocore.compress import maybe_compress_request
from botocore.config import Config
Expand Down Expand Up @@ -148,15 +148,19 @@ def create_client(
region_name, client_config = self._normalize_fips_region(
region_name, client_config
)
if auth := service_model.metadata.get('auth'):
service_signature_version = resolve_auth_type(auth)
else:
service_signature_version = service_model.metadata.get(
'signatureVersion'
)
endpoint_bridge = ClientEndpointBridge(
self._endpoint_resolver,
scoped_config,
client_config,
service_signing_name=service_model.metadata.get('signingName'),
config_store=self._config_store,
service_signature_version=service_model.metadata.get(
'signatureVersion'
),
service_signature_version=service_signature_version,
)
client_args = self._get_client_args(
service_model,
Expand Down Expand Up @@ -487,7 +491,7 @@ def _default_s3_presign_to_sigv2(self, signature_version, **kwargs):
return

if signature_version.startswith('v4-s3express'):
return f'{signature_version}'
return signature_version

for suffix in ['-query', '-presign-post']:
if signature_version.endswith(suffix):
Expand Down Expand Up @@ -953,8 +957,10 @@ def _make_api_call(self, operation_name, api_params):
'client_region': self.meta.region_name,
'client_config': self.meta.config,
'has_streaming_input': operation_model.has_streaming_input,
'auth_type': operation_model.auth_type,
'auth_type': operation_model.resolved_auth_type,
'unsigned_payload': operation_model.unsigned_payload,
}

api_params = self._emit_api_params(
api_params=api_params,
operation_model=operation_model,
Expand Down
7 changes: 7 additions & 0 deletions botocore/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@ class Config:
Defaults to None.
:type sigv4a_signing_region_set: string
:param sigv4a_signing_region_set: A set of AWS regions to apply the signature for
when using SigV4a for signing. Set to ``*`` to represent all regions.
Defaults to None.
:type client_context_params: dict
:param client_context_params: A dictionary of parameters specific to
individual services. If available, valid parameters can be found in
Expand Down Expand Up @@ -257,6 +263,7 @@ class Config:
('request_min_compression_size_bytes', None),
('disable_request_compression', None),
('client_context_params', None),
('sigv4a_signing_region_set', None),
]
)

Expand Down
6 changes: 6 additions & 0 deletions botocore/configprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@
False,
utils.ensure_boolean,
),
'sigv4a_signing_region_set': (
'sigv4a_signing_region_set',
'AWS_SIGV4A_SIGNING_REGION_SET',
None,
None,
),
}
# A mapping for the s3 specific configuration vars. These are the configuration
# vars that typically go in the s3 section of the config file. This mapping
Expand Down
2 changes: 1 addition & 1 deletion botocore/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ class UnknownClientMethodError(BotoCoreError):
class UnsupportedSignatureVersionError(BotoCoreError):
"""Error when trying to use an unsupported Signature Version."""

fmt = 'Signature version is not supported: {signature_version}'
fmt = 'Signature version(s) are not supported: {signature_version}'


class ClientError(Exception):
Expand Down
22 changes: 16 additions & 6 deletions botocore/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,20 @@ def set_operation_specific_signer(context, signing_name, **kwargs):
if auth_type == 'bearer':
return 'bearer'

# If the operation needs an unsigned body, we set additional context
# allowing the signer to be aware of this.
if context.get('unsigned_payload') or auth_type == 'v4-unsigned-body':
context['payload_signing_enabled'] = False

if auth_type.startswith('v4'):
if auth_type == 'v4-s3express':
return auth_type

if auth_type == 'v4a':
# If sigv4a is chosen, we must add additional signing config for
# global signature.
signing = {'region': '*', 'signing_name': signing_name}
region = _resolve_sigv4a_region(context)
signing = {'region': region, 'signing_name': signing_name}
if 'signing' in context:
context['signing'].update(signing)
else:
Expand All @@ -219,11 +225,6 @@ def set_operation_specific_signer(context, signing_name, **kwargs):
else:
signature_version = 'v4'

# If the operation needs an unsigned body, we set additional context
# allowing the signer to be aware of this.
if auth_type == 'v4-unsigned-body':
context['payload_signing_enabled'] = False

# Signing names used by s3 and s3-control use customized signers "s3v4"
# and "s3v4a".
if signing_name in S3_SIGNING_NAMES:
Expand All @@ -232,6 +233,15 @@ def set_operation_specific_signer(context, signing_name, **kwargs):
return signature_version


def _resolve_sigv4a_region(context):
region = None
if 'client_config' in context:
region = context['client_config'].sigv4a_signing_region_set
if not region and context.get('signing', {}).get('region'):
region = context['signing']['region']
return region or '*'


def decode_console_output(parsed, **kwargs):
if 'Output' in parsed:
try:
Expand Down
15 changes: 15 additions & 0 deletions botocore/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from collections import defaultdict
from typing import NamedTuple, Union

from botocore.auth import resolve_auth_type
from botocore.compat import OrderedDict
from botocore.exceptions import (
MissingServiceIdError,
Expand Down Expand Up @@ -623,10 +624,24 @@ def context_parameters(self):
def request_compression(self):
return self._operation_model.get('requestcompression')

@CachedProperty
def auth(self):
return self._operation_model.get('auth')

@CachedProperty
def auth_type(self):
return self._operation_model.get('authtype')

@CachedProperty
def resolved_auth_type(self):
if self.auth:
return resolve_auth_type(self.auth)
return self.auth_type

@CachedProperty
def unsigned_payload(self):
return self._operation_model.get('unsignedPayload')

@CachedProperty
def error_shapes(self):
shapes = self._operation_model.get("errors", [])
Expand Down
4 changes: 3 additions & 1 deletion botocore/regions.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,9 @@ def auth_schemes_to_signing_ctx(self, auth_schemes):
signing_context['region'] = scheme['signingRegion']
elif 'signingRegionSet' in scheme:
if len(scheme['signingRegionSet']) > 0:
signing_context['region'] = scheme['signingRegionSet'][0]
signing_context['region'] = ','.join(
scheme['signingRegionSet']
)
if 'signingName' in scheme:
signing_context.update(signing_name=scheme['signingName'])
if 'disableDoubleEncoding' in scheme:
Expand Down
77 changes: 77 additions & 0 deletions tests/functional/test_auth_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.
import pytest

from botocore.session import get_session

# In the future, a service may have a list of credentials requirements where one
# signature may fail and others may succeed. e.g. a service may want to use bearer
# auth but fall back to sigv4 if a token isn't available. There's currently no way to do
# this in botocore, so this test ensures we handle this gracefully when the need arises.


# The dictionary's value here needs to be hashable to be added to the set below; any
# new auth types with multiple requirements should be added in a comma-separated list
AUTH_TYPE_REQUIREMENTS = {
'aws.auth#sigv4': 'credentials',
'aws.auth#sigv4a': 'credentials',
'smithy.api#httpBearerAuth': 'bearer_token',
'smithy.api#noAuth': 'none',
}


def _all_test_cases():
session = get_session()
loader = session.get_component('data_loader')

services = loader.list_available_services('service-2')
auth_services = []
auth_operations = []

for service in services:
service_model = session.get_service_model(service)
auth_config = service_model.metadata.get('auth', {})
if auth_config:
auth_services.append([service, auth_config])
for operation in service_model.operation_names:
operation_model = service_model.operation_model(operation)
if operation_model.auth:
auth_operations.append([service, operation_model])
return auth_services, auth_operations


AUTH_SERVICES, AUTH_OPERATIONS = _all_test_cases()


@pytest.mark.validates_models
@pytest.mark.parametrize("auth_service, auth_config", AUTH_SERVICES)
def test_all_requirements_match_for_service(auth_service, auth_config):
# Validates that all service-level signature types have the same requirements
message = f'Found mixed signer requirements for service: {auth_service}'
assert_all_requirements_match(auth_config, message)


@pytest.mark.validates_models
@pytest.mark.parametrize("auth_service, operation_model", AUTH_OPERATIONS)
def test_all_requirements_match_for_operation(auth_service, operation_model):
# Validates that all operation-level signature types have the same requirements
message = f'Found mixed signer requirements for operation: {auth_service}.{operation_model.name}'
auth_config = operation_model.auth
assert_all_requirements_match(auth_config, message)


def assert_all_requirements_match(auth_config, message):
auth_requirements = set(
AUTH_TYPE_REQUIREMENTS[auth_type] for auth_type in auth_config
)
assert len(auth_requirements) == 1
42 changes: 42 additions & 0 deletions tests/unit/auth/test_auth_trait.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.

from botocore.auth import BaseSigner, resolve_auth_type
from botocore.exceptions import (
UnknownSignatureVersionError,
UnsupportedSignatureVersionError,
)
from tests import mock, unittest


class TestAuthTraitResolution(unittest.TestCase):
def test_auth_resolves_first_available(self):
auth = ['aws.auth#foo', 'aws.auth#bar']
# Don't declare a signer for "foo"
auth_types = {'bar': mock.Mock(spec=BaseSigner)}
auth_type_conversions = {'aws.auth#foo': 'foo', 'aws.auth#bar': 'bar'}

with mock.patch('botocore.auth.AUTH_TYPE_MAPS', auth_types):
with mock.patch(
'botocore.auth.AUTH_TYPE_TO_SIGNATURE_VERSION',
auth_type_conversions,
):
assert resolve_auth_type(auth) == 'bar'

def test_invalid_auth_type_error(self):
with self.assertRaises(UnknownSignatureVersionError):
resolve_auth_type(['aws.auth#invalidAuth'])

def test_no_known_auth_type(self):
with self.assertRaises(UnsupportedSignatureVersionError):
resolve_auth_type([])
Loading

0 comments on commit 636b5c4

Please sign in to comment.