Skip to content

Commit f825f86

Browse files
feat: move App Sync subscription headers to protocol (#5301)
* chore: move subscription headers to protocol * fix: remove `=` from encoded headers * chore: add comment
1 parent 6aaf966 commit f825f86

File tree

5 files changed

+164
-31
lines changed

5 files changed

+164
-31
lines changed

packages/api/amplify_api_dart/lib/src/decorators/web_socket_auth_utils.dart

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,18 @@ const _requiredHeaders = {
2626
AWSHeaders.contentType: 'application/json; charset=utf-8',
2727
};
2828

29-
// AppSync expects "{}" encoded in the URI as the payload during handshake.
30-
const _emptyBody = <String, dynamic>{};
29+
/// The default payload to include to AppSync.
30+
///
31+
/// AppSync expects "{}" encoded in the URI as the payload during handshake.
32+
@internal
33+
const appSyncDefaultPayload = <String, dynamic>{};
3134

3235
/// Generate a URI for the connection and all subscriptions.
3336
///
3437
/// See https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#handshake-details-to-establish-the-websocket-connection=
35-
Future<Uri> generateConnectionUri(
36-
ApiOutputs config,
37-
AmplifyAuthProviderRepository authRepo,
38-
) async {
39-
// First, generate auth query parameters.
40-
final authorizationHeaders = await _generateAuthorizationHeaders(
41-
config,
42-
isConnectionInit: true,
43-
authRepo: authRepo,
44-
body: _emptyBody,
45-
);
46-
final encodedAuthHeaders =
47-
base64.encode(json.encode(authorizationHeaders).codeUnits);
38+
Future<Uri> generateConnectionUri(ApiOutputs config) async {
4839
final authQueryParameters = {
49-
'header': encodedAuthHeaders,
50-
'payload': base64.encode(utf8.encode(json.encode(_emptyBody))),
40+
'payload': base64.encode(utf8.encode(json.encode(appSyncDefaultPayload))),
5141
};
5242
// Conditionally format the URI for a) AppSync domain b) custom domain.
5343
var endpointUriHost = Uri.parse(config.url).host;
@@ -86,7 +76,7 @@ Future<WebSocketSubscriptionRegistrationMessage>
8676
required GraphQLRequest<T> request,
8777
}) async {
8878
final body = {'variables': request.variables, 'query': request.document};
89-
final authorizationHeaders = await _generateAuthorizationHeaders(
79+
final authorizationHeaders = await generateAuthorizationHeaders(
9080
config,
9181
isConnectionInit: false,
9282
authRepo: authRepo,
@@ -114,7 +104,8 @@ Future<WebSocketSubscriptionRegistrationMessage>
114104
/// a canonical HTTP request that is authorized but never sent. The headers from
115105
/// the HTTP request are reformatted and returned. This logic applies for all auth
116106
/// modes as determined by [authRepo] parameter.
117-
Future<Map<String, String>> _generateAuthorizationHeaders(
107+
@internal
108+
Future<Map<String, String>> generateAuthorizationHeaders(
118109
ApiOutputs config, {
119110
required bool isConnectionInit,
120111
required AmplifyAuthProviderRepository authRepo,

packages/api/amplify_api_dart/lib/src/graphql/web_socket/services/web_socket_service.dart

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import 'package:amplify_api_dart/src/graphql/web_socket/types/subscriptions_even
1212
import 'package:amplify_api_dart/src/graphql/web_socket/types/web_socket_message_stream_transformer.dart';
1313
import 'package:amplify_api_dart/src/graphql/web_socket/types/web_socket_types.dart';
1414
import 'package:amplify_core/amplify_core.dart';
15+
// ignore: implementation_imports
16+
import 'package:amplify_core/src/config/amplify_outputs/api_outputs.dart';
1517
import 'package:async/async.dart';
1618
import 'package:meta/meta.dart';
1719
import 'package:stream_transform/stream_transform.dart';
@@ -72,15 +74,14 @@ class AmplifyWebSocketService
7274
);
7375

7476
try {
75-
const webSocketProtocols = ['graphql-ws'];
76-
final connectionUri = await generateConnectionUri(
77+
final protocols = await generateProtocols(
7778
state.config,
7879
state.authProviderRepo,
7980
);
80-
81+
final connectionUri = await generateConnectionUri(state.config);
8182
final channel = WebSocketChannel.connect(
8283
connectionUri,
83-
protocols: webSocketProtocols,
84+
protocols: protocols,
8485
);
8586
sink = channel.sink;
8687

@@ -95,6 +96,28 @@ class AmplifyWebSocketService
9596
}
9697
}
9798

99+
/// Generates a list of protocols from a [WebSocketState].
100+
@visibleForTesting
101+
Future<List<String>> generateProtocols(
102+
ApiOutputs outputs,
103+
AmplifyAuthProviderRepository authRepo,
104+
) async {
105+
final authorizationHeaders = await generateAuthorizationHeaders(
106+
outputs,
107+
isConnectionInit: true,
108+
authRepo: authRepo,
109+
body: appSyncDefaultPayload,
110+
);
111+
final encodedAuthHeaders = base64Url
112+
.encode(json.encode(authorizationHeaders).codeUnits)
113+
// remove padding char ("=") as it is optional in base64Url encoding and
114+
// is not permitted in protocol names.
115+
// Base64Url Spec: https://datatracker.ietf.org/doc/html/rfc4648#section-5
116+
// Protocol name separators: https://www.rfc-editor.org/rfc/rfc2616 (see "separators")
117+
.replaceAll('=', '');
118+
return ['graphql-ws', 'header-$encodedAuthHeaders'];
119+
}
120+
98121
@override
99122
Future<void> register(
100123
ConnectedState state,

packages/api/amplify_api_dart/test/util.dart

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ const testApiKeyConfigCustomDomain = DataOutputs(
8989
);
9090

9191
const expectedApiKeyWebSocketConnectionUrl =
92-
'wss://abc123.appsync-realtime-api.us-east-1.amazonaws.com/graphql?header=eyJBY2NlcHQiOiJhcHBsaWNhdGlvbi9qc29uLCB0ZXh0L2phdmFzY3JpcHQiLCJDb250ZW50LUVuY29kaW5nIjoiYW16LTEuMCIsIkNvbnRlbnQtVHlwZSI6ImFwcGxpY2F0aW9uL2pzb247IGNoYXJzZXQ9dXRmLTgiLCJYLUFwaS1LZXkiOiJhYmMtMTIzIiwiSG9zdCI6ImFiYzEyMy5hcHBzeW5jLWFwaS51cy1lYXN0LTEuYW1hem9uYXdzLmNvbSJ9&payload=e30%3D';
92+
'wss://abc123.appsync-realtime-api.us-east-1.amazonaws.com/graphql?payload=e30%3D';
9393
const expectedApiKeyWebSocketConnectionUrlCustomDomain =
94-
'wss://foo.bar.aws.dev/graphql/realtime?header=eyJBY2NlcHQiOiJhcHBsaWNhdGlvbi9qc29uLCB0ZXh0L2phdmFzY3JpcHQiLCJDb250ZW50LUVuY29kaW5nIjoiYW16LTEuMCIsIkNvbnRlbnQtVHlwZSI6ImFwcGxpY2F0aW9uL2pzb247IGNoYXJzZXQ9dXRmLTgiLCJYLUFwaS1LZXkiOiJhYmMtMTIzIiwiSG9zdCI6ImZvby5iYXIuYXdzLmRldiJ9&payload=e30%3D';
94+
'wss://foo.bar.aws.dev/graphql/realtime?payload=e30%3D';
9595

9696
AmplifyAuthProviderRepository getTestAuthProviderRepo() {
9797
final testAuthProviderRepo = AmplifyAuthProviderRepository()
@@ -341,3 +341,24 @@ void testQueryPredicateTranslation(
341341
}
342342

343343
final deepEquals = const DeepCollectionEquality().equals;
344+
345+
/// Creates [DataOutputs] and [AmplifyAuthProviderRepository] for use in tests.
346+
(DataOutputs, AmplifyAuthProviderRepository) createOutputsAndRepo(
347+
AmplifyAuthProvider authProvider,
348+
APIAuthorizationType type, [
349+
String? apiKey,
350+
]) {
351+
final repo = AmplifyAuthProviderRepository()
352+
..registerAuthProvider(
353+
type.authProviderToken,
354+
authProvider,
355+
);
356+
final outputs = DataOutputs(
357+
awsRegion: 'us-east-1',
358+
url: 'https://example.com/',
359+
defaultAuthorizationType: type,
360+
authorizationTypes: [type],
361+
apiKey: type == APIAuthorizationType.apiKey ? apiKey : null,
362+
);
363+
return (outputs, repo);
364+
}

packages/api/amplify_api_dart/test/web_socket/web_socket_auth_utils_test.dart

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,17 @@ void main() {
4747
}
4848

4949
group('generateConnectionUri', () {
50-
test('should generate authorized connection URI', () async {
51-
final actualConnectionUri =
52-
await generateConnectionUri(testApiKeyConfig, authProviderRepo);
50+
test('should generate connection URI', () async {
51+
final actualConnectionUri = await generateConnectionUri(testApiKeyConfig);
5352
expect(
5453
actualConnectionUri.toString(),
5554
expectedApiKeyWebSocketConnectionUrl,
5655
);
5756
});
5857

59-
test('should generate authorized connection URI with a custom domain',
60-
() async {
58+
test('should generate connection URI with a custom domain', () async {
6159
final actualConnectionUri = await generateConnectionUri(
6260
testApiKeyConfigCustomDomain,
63-
authProviderRepo,
6461
);
6562
expect(
6663
actualConnectionUri.toString(),
@@ -141,4 +138,68 @@ void main() {
141138
);
142139
});
143140
});
141+
142+
group('generateAuthorizationHeaders', () {
143+
const apiKey = 'fake-key';
144+
145+
test('should generate headers for API key Authorization', () async {
146+
final (outputs, repo) = createOutputsAndRepo(
147+
AppSyncApiKeyAuthProvider(),
148+
APIAuthorizationType.apiKey,
149+
apiKey,
150+
);
151+
final headers = await generateAuthorizationHeaders(
152+
outputs,
153+
isConnectionInit: true,
154+
authRepo: repo,
155+
body: {},
156+
);
157+
expect(headers[xApiKey], apiKey);
158+
expect(headers.containsKey(AWSHeaders.accept), true);
159+
expect(headers.containsKey(AWSHeaders.contentEncoding), true);
160+
expect(headers.containsKey(AWSHeaders.contentType), true);
161+
expect(headers.containsKey(AWSHeaders.host), true);
162+
});
163+
164+
test('should generate headers for IAM Authorization', () async {
165+
final (outputs, repo) = createOutputsAndRepo(
166+
TestIamAuthProvider(),
167+
APIAuthorizationType.iam,
168+
);
169+
final headers = await generateAuthorizationHeaders(
170+
outputs,
171+
isConnectionInit: true,
172+
authRepo: repo,
173+
body: {},
174+
);
175+
expect(
176+
headers['Authorization']!.contains('Credential=fake-access-key-123'),
177+
true,
178+
);
179+
expect(headers.containsKey(AWSHeaders.date), true);
180+
expect(headers.containsKey(AWSHeaders.contentSHA256), true);
181+
expect(headers.containsKey(AWSHeaders.accept), true);
182+
expect(headers.containsKey(AWSHeaders.contentEncoding), true);
183+
expect(headers.containsKey(AWSHeaders.contentType), true);
184+
expect(headers.containsKey(AWSHeaders.host), true);
185+
});
186+
187+
test('should generate headers for user pool Authorization', () async {
188+
final (outputs, repo) = createOutputsAndRepo(
189+
TestTokenAuthProvider(),
190+
APIAuthorizationType.userPools,
191+
);
192+
final headers = await generateAuthorizationHeaders(
193+
outputs,
194+
isConnectionInit: true,
195+
authRepo: repo,
196+
body: {},
197+
);
198+
expect(headers[AWSHeaders.authorization], 'test-access-token-123');
199+
expect(headers.containsKey(AWSHeaders.accept), true);
200+
expect(headers.containsKey(AWSHeaders.contentEncoding), true);
201+
expect(headers.containsKey(AWSHeaders.contentType), true);
202+
expect(headers.containsKey(AWSHeaders.host), true);
203+
});
204+
});
144205
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import 'dart:convert';
5+
6+
import 'package:amplify_api_dart/src/graphql/providers/app_sync_api_key_auth_provider.dart';
7+
import 'package:amplify_api_dart/src/graphql/web_socket/services/web_socket_service.dart';
8+
import 'package:amplify_core/amplify_core.dart';
9+
import 'package:test/test.dart';
10+
11+
import '../util.dart';
12+
13+
void main() {
14+
group('AmplifyWebSocketService', () {
15+
group('generateProtocols', () {});
16+
const apiKey = 'fake-key';
17+
test('should generate a protocol that includes the appropriate headers',
18+
() async {
19+
final (outputs, repo) = createOutputsAndRepo(
20+
AppSyncApiKeyAuthProvider(),
21+
APIAuthorizationType.apiKey,
22+
apiKey,
23+
);
24+
final service = AmplifyWebSocketService();
25+
final protocols = await service.generateProtocols(outputs, repo);
26+
final encodedHeaders = protocols[1].replaceFirst('header-', '');
27+
final headers = json.decode(
28+
String.fromCharCodes(base64Url.decode(encodedHeaders)),
29+
) as Map<String, dynamic>;
30+
expect(headers[xApiKey], apiKey);
31+
expect(headers.containsKey(AWSHeaders.accept), true);
32+
expect(headers.containsKey(AWSHeaders.contentEncoding), true);
33+
expect(headers.containsKey(AWSHeaders.contentType), true);
34+
expect(headers.containsKey(AWSHeaders.host), true);
35+
});
36+
});
37+
}

0 commit comments

Comments
 (0)