From d6f640513532ccdabf4baf64be21135382fa8877 Mon Sep 17 00:00:00 2001 From: Travis Sheppard Date: Thu, 21 Jul 2022 12:50:37 -0800 Subject: [PATCH] feat(core,api): IAM auth mode for HTTP requests (REST and GQL) (#1893) --- .../api/auth/api_authorization_type.dart | 2 +- .../types/common/amplify_auth_provider.dart | 14 ++ .../amplify_authorization_rest_client.dart | 30 ++-- .../amplify_api/lib/src/api_plugin_impl.dart | 64 ++++--- .../decorators/authorize_http_request.dart | 110 ++++++++++++ .../app_sync_api_key_auth_provider.dart | 38 +++++ packages/api/amplify_api/pubspec.yaml | 1 + .../test/amplify_dart_rest_methods_test.dart | 5 +- .../test/authorize_http_request_test.dart | 159 ++++++++++++++++++ .../amplify_api/test/dart_graphql_test.dart | 2 +- .../test/plugin_configuration_test.dart | 112 ++++++++++++ packages/api/amplify_api/test/util.dart | 53 ++++++ 12 files changed, 538 insertions(+), 52 deletions(-) create mode 100644 packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart create mode 100644 packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart create mode 100644 packages/api/amplify_api/test/authorize_http_request_test.dart create mode 100644 packages/api/amplify_api/test/plugin_configuration_test.dart create mode 100644 packages/api/amplify_api/test/util.dart diff --git a/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart b/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart index f15da13b9f..95b73a4cac 100644 --- a/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart +++ b/packages/amplify_core/lib/src/types/api/auth/api_authorization_type.dart @@ -35,7 +35,7 @@ enum APIAuthorizationType { /// See also: /// - [API Key Authorization](https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#api-key-authorization) @JsonValue('API_KEY') - apiKey(AmplifyAuthProviderToken()), + apiKey(AmplifyAuthProviderToken()), /// Use an IAM access/secret key credential pair to authorize access to an API. /// diff --git a/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart b/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart index 30c00ff053..16707d6afd 100644 --- a/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart +++ b/packages/amplify_core/lib/src/types/common/amplify_auth_provider.dart @@ -34,6 +34,12 @@ class IamAuthProviderOptions extends AuthProviderOptions { const IamAuthProviderOptions({required this.region, required this.service}); } +class ApiKeyAuthProviderOptions extends AuthProviderOptions { + final String apiKey; + + const ApiKeyAuthProviderOptions(this.apiKey); +} + abstract class AmplifyAuthProvider { Future authorizeRequest( AWSBaseHttpRequest request, { @@ -50,6 +56,14 @@ abstract class AWSIamAmplifyAuthProvider extends AmplifyAuthProvider }); } +abstract class ApiKeyAmplifyAuthProvider extends AmplifyAuthProvider { + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + covariant ApiKeyAuthProviderOptions? options, + }); +} + abstract class TokenAmplifyAuthProvider extends AmplifyAuthProvider { Future getLatestAuthToken(); diff --git a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart index 8a2d0678b5..a0b7aece44 100644 --- a/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart +++ b/packages/api/amplify_api/lib/src/amplify_authorization_rest_client.dart @@ -18,15 +18,19 @@ import 'package:amplify_core/amplify_core.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; -const _xApiKey = 'X-Api-Key'; +import 'decorators/authorize_http_request.dart'; /// Implementation of http [http.Client] that authorizes HTTP requests with /// Amplify. @internal class AmplifyAuthorizationRestClient extends http.BaseClient implements Closeable { + /// [AmplifyAuthProviderRepository] for any auth modes this client may use. + final AmplifyAuthProviderRepository authProviderRepo; + /// Determines how requests with this client are authorized. final AWSApiConfig endpointConfig; + final http.Client _baseClient; final bool _useDefaultBaseClient; @@ -34,6 +38,7 @@ class AmplifyAuthorizationRestClient extends http.BaseClient /// client are authorized. AmplifyAuthorizationRestClient({ required this.endpointConfig, + required this.authProviderRepo, http.Client? baseClient, }) : _useDefaultBaseClient = baseClient == null, _baseClient = baseClient ?? http.Client(); @@ -42,27 +47,14 @@ class AmplifyAuthorizationRestClient extends http.BaseClient /// header already set. @override Future send(http.BaseRequest request) async => - _baseClient.send(_authorizeRequest(request)); + _baseClient.send(await authorizeHttpRequest( + request, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + )); @override void close() { if (_useDefaultBaseClient) _baseClient.close(); } - - http.BaseRequest _authorizeRequest(http.BaseRequest request) { - if (!request.headers.containsKey(AWSHeaders.authorization) && - endpointConfig.authorizationType != APIAuthorizationType.none) { - // TODO(ragingsquirrel3): Use auth providers from core to transform the request. - final apiKey = endpointConfig.apiKey; - if (endpointConfig.authorizationType == APIAuthorizationType.apiKey) { - if (apiKey == null) { - throw const ApiException( - 'Auth mode is API Key, but no API Key was found in config.'); - } - - request.headers.putIfAbsent(_xApiKey, () => apiKey); - } - } - return request; - } } diff --git a/packages/api/amplify_api/lib/src/api_plugin_impl.dart b/packages/api/amplify_api/lib/src/api_plugin_impl.dart index a5dfd58ce6..e353c70a31 100644 --- a/packages/api/amplify_api/lib/src/api_plugin_impl.dart +++ b/packages/api/amplify_api/lib/src/api_plugin_impl.dart @@ -26,6 +26,7 @@ import 'package:meta/meta.dart'; import 'amplify_api_config.dart'; import 'amplify_authorization_rest_client.dart'; +import 'graphql/app_sync_api_key_auth_provider.dart'; import 'graphql/send_graphql_request.dart'; import 'util.dart'; @@ -35,10 +36,11 @@ import 'util.dart'; class AmplifyAPIDart extends AmplifyAPI { late final AWSApiPluginConfig _apiConfig; final http.Client? _baseHttpClient; + late final AmplifyAuthProviderRepository _authProviderRepo; /// A map of the keys from the Amplify API config to HTTP clients to use for /// requests to that endpoint. - final Map _clientPool = {}; + final Map _clientPool = {}; /// The registered [APIAuthProvider] instances. final Map _authProviders = {}; @@ -65,6 +67,21 @@ class AmplifyAPIDart extends AmplifyAPI { 'https://docs.amplify.aws/lib/graphqlapi/getting-started/q/platform/flutter/#configure-api'); } _apiConfig = apiConfig; + _authProviderRepo = authProviderRepo; + _registerApiPluginAuthProviders(); + } + + /// If an endpoint has an API key, ensure valid auth provider registered. + void _registerApiPluginAuthProviders() { + _apiConfig.endpoints.forEach((key, value) { + // Check the presence of apiKey (not auth type) because other modes might + // have a key if not the primary auth mode. + if (value.apiKey != null) { + _authProviderRepo.registerAuthProvider( + value.authorizationType.authProviderToken, + AppSyncApiKeyAuthProvider()); + } + }); } @override @@ -89,32 +106,21 @@ class AmplifyAPIDart extends AmplifyAPI { } } - /// Returns the HTTP client to be used for GraphQL operations. + /// Returns the HTTP client to be used for REST/GraphQL operations. /// - /// Use [apiName] if there are multiple GraphQL endpoints. + /// Use [apiName] if there are multiple endpoints of the same type. @visibleForTesting - http.Client getGraphQLClient({String? apiName}) { + http.Client getHttpClient(EndpointType type, {String? apiName}) { final endpoint = _apiConfig.getEndpoint( - type: EndpointType.graphQL, + type: type, apiName: apiName, ); - return _clientPool[endpoint.name] ??= AmplifyAuthorizationRestClient( - endpointConfig: endpoint.config, baseClient: _baseHttpClient); - } - - /// Returns the HTTP client to be used for REST operations. - /// - /// Use [apiName] if there are multiple REST endpoints. - @visibleForTesting - http.Client getRestClient({String? apiName}) { - final endpoint = _apiConfig.getEndpoint( - type: EndpointType.rest, - apiName: apiName, - ); - return _clientPool[endpoint.name] ??= AmplifyAuthorizationRestClient( + return _clientPool[endpoint.name] ??= AmplifyHttpClient( + baseClient: AmplifyAuthorizationRestClient( endpointConfig: endpoint.config, baseClient: _baseHttpClient, - ); + authProviderRepo: _authProviderRepo, + )); } Uri _getGraphQLUri(String? apiName) { @@ -160,7 +166,8 @@ class AmplifyAPIDart extends AmplifyAPI { @override CancelableOperation> query( {required GraphQLRequest request}) { - final graphQLClient = getGraphQLClient(apiName: request.apiName); + final graphQLClient = + getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); final responseFuture = sendGraphQLRequest( @@ -171,7 +178,8 @@ class AmplifyAPIDart extends AmplifyAPI { @override CancelableOperation> mutate( {required GraphQLRequest request}) { - final graphQLClient = getGraphQLClient(apiName: request.apiName); + final graphQLClient = + getHttpClient(EndpointType.graphQL, apiName: request.apiName); final uri = _getGraphQLUri(request.apiName); final responseFuture = sendGraphQLRequest( @@ -190,7 +198,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse(AWSStreamedHttpRequest.delete( uri, body: body ?? HttpPayload.empty(), @@ -206,7 +214,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSHttpRequest.get( uri, @@ -223,7 +231,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSHttpRequest.head( uri, @@ -241,7 +249,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSStreamedHttpRequest.patch( uri, @@ -260,7 +268,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSStreamedHttpRequest.post( uri, @@ -279,7 +287,7 @@ class AmplifyAPIDart extends AmplifyAPI { String? apiName, }) { final uri = _getRestUri(path, apiName, queryParameters); - final client = getRestClient(apiName: apiName); + final client = getHttpClient(EndpointType.rest, apiName: apiName); return _prepareRestResponse( AWSStreamedHttpRequest.put( uri, diff --git a/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart b/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart new file mode 100644 index 0000000000..3cab4d7443 --- /dev/null +++ b/packages/api/amplify_api/lib/src/decorators/authorize_http_request.dart @@ -0,0 +1,110 @@ +// Copyright 2022 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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 'dart:async'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +/// Transforms an HTTP request according to auth providers that match the endpoint +/// configuration. +@internal +Future authorizeHttpRequest(http.BaseRequest request, + {required AWSApiConfig endpointConfig, + required AmplifyAuthProviderRepository authProviderRepo}) async { + if (request.headers.containsKey(AWSHeaders.authorization)) { + return request; + } + final authType = endpointConfig.authorizationType; + + switch (authType) { + case APIAuthorizationType.apiKey: + final authProvider = _validateAuthProvider( + authProviderRepo + .getAuthProvider(APIAuthorizationType.apiKey.authProviderToken), + authType); + final apiKey = endpointConfig.apiKey; + if (apiKey == null) { + throw const ApiException( + 'Auth mode is API Key, but no API Key was found in config.'); + } + + final authorizedRequest = await authProvider.authorizeRequest( + _httpToAWSRequest(request), + options: ApiKeyAuthProviderOptions(apiKey)); + return authorizedRequest.httpRequest; + case APIAuthorizationType.iam: + final authProvider = _validateAuthProvider( + authProviderRepo + .getAuthProvider(APIAuthorizationType.iam.authProviderToken), + authType); + final service = endpointConfig.endpointType == EndpointType.graphQL + ? AWSService.appSync + : AWSService.apiGatewayManagementApi; // resolves to "execute-api" + + final authorizedRequest = await authProvider.authorizeRequest( + _httpToAWSRequest(request), + options: IamAuthProviderOptions( + region: endpointConfig.region, + service: service, + ), + ); + return authorizedRequest.httpRequest; + case APIAuthorizationType.function: + case APIAuthorizationType.oidc: + case APIAuthorizationType.userPools: + throw UnimplementedError('${authType.name} not implemented.'); + case APIAuthorizationType.none: + return request; + } +} + +T _validateAuthProvider( + T? authProvider, APIAuthorizationType authType) { + if (authProvider == null) { + throw ApiException('No auth provider found for auth mode ${authType.name}.', + recoverySuggestion: 'Ensure auth plugin correctly configured.'); + } + return authProvider; +} + +AWSBaseHttpRequest _httpToAWSRequest(http.BaseRequest request) { + final method = AWSHttpMethod.fromString(request.method); + if (request is http.Request) { + return AWSHttpRequest( + method: method, + uri: request.url, + headers: { + AWSHeaders.contentType: 'application/x-amz-json-1.1', + ...request.headers, + }, + body: request.bodyBytes, + ); + } else if (request is http.StreamedRequest) { + return AWSStreamedHttpRequest( + method: method, + uri: request.url, + headers: { + AWSHeaders.contentType: 'application/x-amz-json-1.1', + ...request.headers, + }, + body: request.finalize(), + ); + } else { + throw UnimplementedError( + 'Multipart HTTP requests are not supported.', + ); + } +} diff --git a/packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart b/packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart new file mode 100644 index 0000000000..bdafe6dbed --- /dev/null +++ b/packages/api/amplify_api/lib/src/graphql/app_sync_api_key_auth_provider.dart @@ -0,0 +1,38 @@ +// Copyright 2022 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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 'dart:async'; + +import 'package:amplify_core/amplify_core.dart'; +import 'package:meta/meta.dart'; + +/// "X-Api-Key", key used for API key header in API key auth mode. +const xApiKey = 'X-Api-Key'; + +/// [AmplifyAuthProvider] implementation that puts an API key in the header. +@internal +class AppSyncApiKeyAuthProvider extends ApiKeyAmplifyAuthProvider { + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + ApiKeyAuthProviderOptions? options, + }) async { + if (options == null) { + throw const ApiException( + 'Called API key auth provider without passing a valid API key.'); + } + request.headers.putIfAbsent(xApiKey, () => options.apiKey); + return request; + } +} diff --git a/packages/api/amplify_api/pubspec.yaml b/packages/api/amplify_api/pubspec.yaml index c82797fc29..a4b2121efe 100644 --- a/packages/api/amplify_api/pubspec.yaml +++ b/packages/api/amplify_api/pubspec.yaml @@ -29,6 +29,7 @@ dev_dependencies: path: ../../amplify_lints amplify_test: path: ../../amplify_test + aws_signature_v4: ^0.1.0 build_runner: ^2.0.0 flutter_test: sdk: flutter diff --git a/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart b/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart index d8c5162377..8469354830 100644 --- a/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart +++ b/packages/api/amplify_api/test/amplify_dart_rest_methods_test.dart @@ -11,8 +11,6 @@ // 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 'dart:convert'; - import 'package:amplify_api/amplify_api.dart'; import 'package:amplify_api/src/api_plugin_impl.dart'; import 'package:amplify_core/amplify_core.dart'; @@ -28,7 +26,8 @@ const _pathThatShouldFail = 'notHere'; class MockAmplifyAPI extends AmplifyAPIDart { @override - http.Client getRestClient({String? apiName}) => MockClient((request) async { + http.Client getHttpClient(EndpointType type, {String? apiName}) => + MockClient((request) async { if (request.body.isNotEmpty) { expect(request.headers['Content-Type'], 'application/json'); } diff --git a/packages/api/amplify_api/test/authorize_http_request_test.dart b/packages/api/amplify_api/test/authorize_http_request_test.dart new file mode 100644 index 0000000000..2179a07ad8 --- /dev/null +++ b/packages/api/amplify_api/test/authorize_http_request_test.dart @@ -0,0 +1,159 @@ +// Copyright 2022 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 'package:amplify_api/src/decorators/authorize_http_request.dart'; +import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'util.dart'; + +const _region = 'us-east-1'; +const _gqlEndpoint = + 'https://abc123.appsync-api.$_region.amazonaws.com/graphql'; +const _restEndpoint = 'https://xyz456.execute-api.$_region.amazonaws.com/test'; + +http.Request _generateTestRequest(String url) { + return http.Request('GET', Uri.parse(url)); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final authProviderRepo = AmplifyAuthProviderRepository(); + + setUpAll(() { + authProviderRepo.registerAuthProvider( + APIAuthorizationType.apiKey.authProviderToken, + AppSyncApiKeyAuthProvider()); + authProviderRepo.registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, TestIamAuthProvider()); + }); + + group('authorizeHttpRequest', () { + test('no-op for auth mode NONE', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.none, + endpoint: _restEndpoint, + endpointType: EndpointType.rest, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + expect(authorizedRequest.headers.containsKey(AWSHeaders.authorization), + isFalse); + expect(authorizedRequest, inputRequest); + }); + + test('no-op for request with Authorization header already set', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.userPools, + endpoint: _restEndpoint, + endpointType: EndpointType.rest, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + const testAuthValue = 'foo'; + inputRequest.headers + .putIfAbsent(AWSHeaders.authorization, () => testAuthValue); + + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + expect( + authorizedRequest.headers[AWSHeaders.authorization], testAuthValue); + expect(authorizedRequest, inputRequest); + }); + + test('authorizes request with IAM auth provider', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.iam, + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + validateSignedRequest(authorizedRequest); + }); + + test('authorizes request with API key', () async { + const testApiKey = 'abc-123-fake-key'; + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.apiKey, + apiKey: testApiKey, + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + final authorizedRequest = await authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ); + expect( + authorizedRequest.headers[xApiKey], + testApiKey, + ); + }); + + test('throws when API key not in config', () async { + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.apiKey, + // no apiKey value provided + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + expectLater( + authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: authProviderRepo, + ), + throwsA(isA())); + }); + + test('authorizes with Cognito User Pools auth mode', () {}, skip: true); + + test('authorizes with OIDC auth mode', () {}, skip: true); + + test('authorizes with lambda auth mode', () {}, skip: true); + + test('throws when no auth provider found', () async { + final emptyAuthRepo = AmplifyAuthProviderRepository(); + const endpointConfig = AWSApiConfig( + authorizationType: APIAuthorizationType.apiKey, + apiKey: 'abc-123-fake-key', + endpoint: _gqlEndpoint, + endpointType: EndpointType.graphQL, + region: _region); + final inputRequest = _generateTestRequest(endpointConfig.endpoint); + expectLater( + authorizeHttpRequest( + inputRequest, + endpointConfig: endpointConfig, + authProviderRepo: emptyAuthRepo, + ), + throwsA(isA())); + }); + }); +} diff --git a/packages/api/amplify_api/test/dart_graphql_test.dart b/packages/api/amplify_api/test/dart_graphql_test.dart index bedd0092f2..4d9d8ec47f 100644 --- a/packages/api/amplify_api/test/dart_graphql_test.dart +++ b/packages/api/amplify_api/test/dart_graphql_test.dart @@ -91,7 +91,7 @@ class MockAmplifyAPI extends AmplifyAPIDart { }) : super(modelProvider: modelProvider); @override - http.Client getGraphQLClient({String? apiName}) => + http.Client getHttpClient(EndpointType type, {String? apiName}) => MockClient((request) async { if (request.body.contains('getBlog')) { return http.Response(json.encode(_expectedModelQueryResult), 200); diff --git a/packages/api/amplify_api/test/plugin_configuration_test.dart b/packages/api/amplify_api/test/plugin_configuration_test.dart new file mode 100644 index 0000000000..fcf3692114 --- /dev/null +++ b/packages/api/amplify_api/test/plugin_configuration_test.dart @@ -0,0 +1,112 @@ +// Copyright 2022 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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 'dart:convert'; + +import 'package:amplify_api/src/api_plugin_impl.dart'; +import 'package:amplify_api/src/graphql/app_sync_api_key_auth_provider.dart'; +import 'package:amplify_core/amplify_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'test_data/fake_amplify_configuration.dart'; +import 'util.dart'; + +const _expectedQuerySuccessResponseBody = { + 'data': { + 'listBlogs': { + 'items': [ + { + 'id': 'TEST_ID', + 'name': 'Test App Blog', + 'createdAt': '2022-06-28T17:36:52.460Z' + } + ] + } + } +}; + +/// Asserts user agent and API key present. +final _mockGqlClient = MockClient((request) async { + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect(request.headers[userAgentHeader], contains('amplify-flutter')); + expect(request.headers[xApiKey], isA()); + return http.Response(json.encode(_expectedQuerySuccessResponseBody), 200); +}); + +/// Asserts user agent and signed. +final _mockRestClient = MockClient((request) async { + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect(request.headers[userAgentHeader], contains('amplify-flutter')); + validateSignedRequest(request); + return http.Response('"Hello from Lambda!"', 200); +}); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final authProviderRepo = AmplifyAuthProviderRepository(); + authProviderRepo.registerAuthProvider( + APIAuthorizationType.iam.authProviderToken, TestIamAuthProvider()); + final config = + AmplifyConfig.fromJson(jsonDecode(amplifyconfig) as Map); + + group('AmplifyAPI plugin configuration', () { + test( + 'should register an API key auth provider when the configuration has an API key', + () async { + final plugin = AmplifyAPIDart(); + await plugin.configure( + authProviderRepo: authProviderRepo, config: config); + final apiKeyAuthProvider = authProviderRepo + .getAuthProvider(APIAuthorizationType.apiKey.authProviderToken); + expect(apiKeyAuthProvider, isA()); + }); + + test( + 'should configure an HTTP client for GraphQL that authorizes with auth providers and adds user-agent', + () async { + final plugin = AmplifyAPIDart(baseHttpClient: _mockGqlClient); + await plugin.configure( + authProviderRepo: authProviderRepo, config: config); + + String graphQLDocument = '''query TestQuery { + listBlogs { + items { + id + name + createdAt + } + } + }'''; + final request = + GraphQLRequest(document: graphQLDocument, variables: {}); + await plugin.query(request: request).value; + // no assertion here because assertion implemented in mock HTTP client + }); + + test( + 'should configure an HTTP client for REST that authorizes with auth providers and adds user-agent', + () async { + final plugin = AmplifyAPIDart(baseHttpClient: _mockRestClient); + await plugin.configure( + authProviderRepo: authProviderRepo, config: config); + + await plugin.get('/items').value; + // no assertion here because assertion implemented in mock HTTP client + }); + }); +} diff --git a/packages/api/amplify_api/test/util.dart b/packages/api/amplify_api/test/util.dart new file mode 100644 index 0000000000..f3c2ef551e --- /dev/null +++ b/packages/api/amplify_api/test/util.dart @@ -0,0 +1,53 @@ +// Copyright 2022 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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 'package:amplify_core/amplify_core.dart'; +import 'package:aws_signature_v4/aws_signature_v4.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +class TestIamAuthProvider extends AWSIamAmplifyAuthProvider { + @override + Future retrieve() async { + return const AWSCredentials( + 'fake-access-key-123', 'fake-secret-access-key-456'); + } + + @override + Future authorizeRequest( + AWSBaseHttpRequest request, { + IamAuthProviderOptions? options, + }) async { + final signer = AWSSigV4Signer( + credentialsProvider: AWSCredentialsProvider(await retrieve()), + ); + final scope = AWSCredentialScope( + region: options!.region, + service: AWSService.appSync, + ); + return signer.sign( + request, + credentialScope: scope, + ); + } +} + +void validateSignedRequest(http.BaseRequest request) { + const userAgentHeader = + zIsWeb ? AWSHeaders.amzUserAgent : AWSHeaders.userAgent; + expect( + request.headers[userAgentHeader], + contains('aws-sigv4'), + ); +}