Skip to content

Commit eb72956

Browse files
committed
feat(auth): Refuse addPlugin from a secondary isolate without a messenger (#5302)
Second of the per-category #5302 guardrails. `AmplifyAuthCognito.addPlugin` registers with the process-wide native Cognito SDK, starting with `NativeAuthPlugin.setUp`. Without a guard a secondary isolate got a bare `StateError` about `BackgroundIsolateBinaryMessenger` from inside Pigeon, which says nothing about Amplify, Auth or isolates. Gated on messenger availability rather than root-isolate identity. A headless `FlutterEngine` always has a working messenger, so the guard provably cannot fire there -- which matters because `amplifyBackgroundProcessing` adds this very plugin from such an engine. The broader root-token check would have rested on an unverified claim about headless engines, and the cost of being wrong is broken push handling in shipped apps. No duplicated predicate: amplify_auth_cognito already depends on amplify_flutter, so it imports the canonical `@internal` getter directly. Documents, in code and in an executable test, that this does not catch a secondary isolate which has already been bootstrapped with a messenger. That case cannot work regardless: Flutter itself refuses it with `UnsupportedError: Background isolates do not support setMessageHandler(). Messages from the host platform always go to the root isolate.` Host-to-Dart callbacks are impossible off the root isolate, which bounds what #5302 can ever support.
1 parent 1f11e54 commit eb72956

2 files changed

Lines changed: 208 additions & 0 deletions

File tree

packages/auth/amplify_auth_cognito/lib/src/auth_plugin_impl.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import 'package:amplify_auth_cognito_dart/src/state/cognito_state_machine.dart';
2323
// ignore: implementation_imports, invalid_use_of_internal_member
2424
import 'package:amplify_auth_cognito_dart/src/state/state.dart';
2525
import 'package:amplify_core/amplify_core.dart';
26+
// ignore: implementation_imports
27+
import 'package:amplify_flutter/src/amplify_isolate.dart';
2628
import 'package:amplify_secure_storage/amplify_secure_storage.dart';
2729
import 'package:flutter/foundation.dart';
2830

@@ -57,6 +59,40 @@ class AmplifyAuthCognito extends AmplifyAuthCognitoDart with AWSDebuggable {
5759
return;
5860
}
5961

62+
// Everything below reaches the process-wide native Cognito SDK, starting
63+
// with `NativeAuthPlugin.setUp`, which registers a handler on this isolate's
64+
// binary messenger.
65+
//
66+
// Gated on messenger availability rather than on root-isolate identity: a
67+
// headless `FlutterEngine` always has a working messenger, so this can never
68+
// fire there. That matters because `amplifyBackgroundProcessing` adds this
69+
// very plugin from such an engine, and breaking it would break recording
70+
// push notifications while the app is killed.
71+
//
72+
// KNOWN GAP: this does not catch a secondary isolate that has already been
73+
// bootstrapped with `BackgroundIsolateBinaryMessenger.ensureInitialized`.
74+
// That isolate has a messenger and passes this check, but it still cannot
75+
// work: `NativeAuthPlugin.setUp` below fails with `UnsupportedError:
76+
// Background isolates do not support setMessageHandler(). Messages from the
77+
// host platform always go to the root isolate.` Host-to-Dart callbacks are
78+
// impossible in any secondary isolate, so that case is broken by Flutter
79+
// rather than by Amplify. Reporting it clearly needs root-isolate identity,
80+
// which is only safe to rely on once a headless engine's token has been
81+
// verified on a device. See #5302.
82+
// ignore: invalid_use_of_internal_member
83+
if (!amplifyIsolateIsInitialized) {
84+
throw PluginError(
85+
'AmplifyAuthCognito cannot be added from a secondary isolate with no '
86+
'platform channel access. The native Cognito SDK is a single instance '
87+
'per process and belongs to the root isolate.',
88+
recoverySuggestion:
89+
'Add AmplifyAuthCognito and call Amplify.configure on the root '
90+
'isolate, and use the Auth category from there. Auth flows which '
91+
'run entirely in Dart are available from AmplifyAuthCognitoDart, '
92+
'which can be configured in a secondary isolate.',
93+
);
94+
}
95+
6096
// Configure this plugin to act as a native iOS/Android plugin.
6197
final nativePlugin = _NativeAmplifyAuthCognito(stateMachine);
6298
NativeAuthPlugin.setUp(nativePlugin);
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Guardrail for https://github.com/aws-amplify/amplify-flutter/issues/5302.
5+
//
6+
// `AmplifyAuthCognito` registers itself with the process-wide native Cognito SDK
7+
// during `addPlugin`, starting with `NativeAuthPlugin.setUp`. Without this guard
8+
// a secondary isolate gets a bare `StateError` about
9+
// `BackgroundIsolateBinaryMessenger` from deep inside Pigeon, which says nothing
10+
// about Amplify, Auth, or isolates.
11+
//
12+
// The guard is gated on messenger availability, not root-isolate identity, so it
13+
// provably cannot fire in a headless `FlutterEngine` — see the comment at the
14+
// call site. The cost is a known gap, pinned by the last test in this file.
15+
16+
@TestOn('vm')
17+
library;
18+
19+
import 'dart:async';
20+
import 'dart:isolate';
21+
22+
import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
23+
import 'package:amplify_core/amplify_core.dart';
24+
// ignore: implementation_imports
25+
import 'package:amplify_flutter/src/amplify_isolate.dart';
26+
import 'package:flutter/services.dart';
27+
import 'package:flutter_test/flutter_test.dart';
28+
29+
/// What [addPluginInIsolate] observed, sent back over a [SendPort].
30+
typedef IsolateReport = Map<String, Object?>;
31+
32+
/// Adds [AmplifyAuthCognito] in the isolate this runs in.
33+
///
34+
/// When [message] carries a [RootIsolateToken], the isolate is bootstrapped with
35+
/// a binary messenger first, which is the case the guard deliberately does not
36+
/// catch.
37+
Future<void> addPluginInIsolate((SendPort, RootIsolateToken?) message) async {
38+
final (sendPort, token) = message;
39+
final report = <String, Object?>{};
40+
if (token != null) {
41+
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
42+
}
43+
// ignore: invalid_use_of_internal_member
44+
report['isolateIsInitialized'] = amplifyIsolateIsInitialized;
45+
try {
46+
await AmplifyAuthCognito().addPlugin(
47+
authProviderRepo: AmplifyAuthProviderRepository(),
48+
);
49+
report['errorType'] = null;
50+
} on Object catch (e) {
51+
report['errorType'] = e.runtimeType.toString();
52+
report['error'] = '$e';
53+
}
54+
sendPort.send(report);
55+
}
56+
57+
/// Spawns [addPluginInIsolate] and waits for its [IsolateReport].
58+
Future<IsolateReport> runInIsolate({RootIsolateToken? token}) async {
59+
final receivePort = ReceivePort();
60+
final errorPort = ReceivePort();
61+
final result = Completer<IsolateReport>();
62+
63+
receivePort.listen((message) {
64+
if (!result.isCompleted) {
65+
result.complete((message as Map).cast<String, Object?>());
66+
}
67+
});
68+
errorPort.listen((message) {
69+
if (!result.isCompleted) {
70+
result.completeError(StateError('Isolate errored: $message'));
71+
}
72+
});
73+
74+
final isolate = await Isolate.spawn(
75+
addPluginInIsolate,
76+
(receivePort.sendPort, token),
77+
onError: errorPort.sendPort,
78+
errorsAreFatal: true,
79+
debugName: 'auth-guard',
80+
);
81+
try {
82+
return await result.future.timeout(const Duration(seconds: 30));
83+
} finally {
84+
isolate.kill(priority: Isolate.immediate);
85+
receivePort.close();
86+
errorPort.close();
87+
}
88+
}
89+
90+
void main() {
91+
TestWidgetsFlutterBinding.ensureInitialized();
92+
93+
group('AmplifyAuthCognito.addPlugin', () {
94+
test('is refused in a secondary isolate with no messenger', () async {
95+
final report = await runInIsolate();
96+
97+
expect(report['isolateIsInitialized'], isFalse);
98+
expect(report['errorType'], 'PluginError');
99+
expect(report['error'], contains('secondary isolate'));
100+
expect(
101+
report['error'],
102+
contains('single instance'),
103+
reason: 'The message must name the real constraint',
104+
);
105+
expect(
106+
report['error'],
107+
contains('root isolate'),
108+
reason: 'The message must say where to configure instead',
109+
);
110+
expect(
111+
report['error'],
112+
contains('AmplifyAuthCognitoDart'),
113+
reason: 'The message must point at the Dart-only alternative',
114+
);
115+
});
116+
117+
test('is not refused on the root isolate', () async {
118+
// The guard must be invisible to every single-isolate app, and to the
119+
// headless `FlutterEngine` that `amplifyBackgroundProcessing` runs in —
120+
// both have a messenger.
121+
// ignore: invalid_use_of_internal_member
122+
expect(amplifyIsolateIsInitialized, isTrue);
123+
124+
await expectLater(
125+
AmplifyAuthCognito().addPlugin(
126+
authProviderRepo: AmplifyAuthProviderRepository(),
127+
),
128+
completes,
129+
);
130+
});
131+
132+
// KNOWN GAP, pinned deliberately. A secondary isolate bootstrapped with
133+
// `BackgroundIsolateBinaryMessenger.ensureInitialized` has a messenger, so
134+
// the guard lets it through. It still cannot work, but for a reason that
135+
// belongs to Flutter rather than Amplify: registering a host-to-Dart handler
136+
// is unsupported off the root isolate, so `NativeAuthPlugin.setUp` throws
137+
// `UnsupportedError`. Reporting that clearly would need root-isolate
138+
// identity, which is only safe once a headless engine's token has been
139+
// verified on a device. Whoever closes this gap should expect this to change.
140+
test(
141+
'is NOT refused in a bootstrapped secondary isolate (known gap)',
142+
() async {
143+
final report = await runInIsolate(token: RootIsolateToken.instance);
144+
145+
expect(
146+
report['isolateIsInitialized'],
147+
isTrue,
148+
reason: 'Bootstrapping gave this isolate a messenger',
149+
);
150+
// ignore: avoid_print
151+
print('GAP_ERROR=${report['errorType']}: ${report['error']}');
152+
153+
// The guard did not fire...
154+
expect(
155+
report['errorType'],
156+
isNot('PluginError'),
157+
reason: 'Gating on the messenger deliberately lets this case through',
158+
);
159+
// ...but Flutter forbids it anyway, one layer deeper.
160+
expect(report['errorType'], 'UnsupportedError');
161+
expect(report['error'], contains('setMessageHandler'));
162+
expect(
163+
report['error'],
164+
contains('always go to the root isolate'),
165+
reason:
166+
'Host-to-Dart callbacks are impossible in any secondary isolate, '
167+
'which constrains what #5302 can ever support',
168+
);
169+
},
170+
);
171+
});
172+
}

0 commit comments

Comments
 (0)