Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class OpenIdMfaWaitingScreen extends HookConsumerWidget {
.catchError((error) {
if (isGone()) return;
host.reportFailure(
message: ErrorHandler.getHumanReadableError(error),
message: ErrorHandler.getOpenIdMfaError(error),
logMessage: "OpenID MFA polling error!",
error: error,
);
Expand Down
57 changes: 51 additions & 6 deletions client/lib/open/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,15 @@ class MfaMethodNotAvailableException implements Exception {
'Requested MFA method is not available on the account: ${method.toReadableString()}';
}

enum MfaRequestFailure { network, policyDenied, attemptLimit, notReady, server }
enum MfaRequestFailure {
network,
policyDenied,
attemptLimit,
sessionExpired,
staleAttempt,
notReady,
server,
}

class MfaRequestException implements Exception {
final String operation;
Expand All @@ -69,10 +77,38 @@ class MfaRequestException implements Exception {
: '$operation failed. Status: $statusCode';
}

MfaRequestFailure? _mfaResponseFailure(
DioException error, {
bool emptyForbiddenIsAttemptLimit = false,
}) {
final response = error.response;
final data = response?.data;
final message = data is Map ? data['error'] : null;

if (response?.statusCode == 400 && message == 'login session not found') {
return MfaRequestFailure.sessionExpired;
}
if (response?.statusCode == 401 && message == 'invalid token') {
return MfaRequestFailure.sessionExpired;
}
if (response?.statusCode == 400 && message == 'stale MFA attempt') {
return MfaRequestFailure.staleAttempt;
}
if (response?.statusCode == 403 &&
(message ==
'Too many failed MFA attempts. Please try connecting again.' ||
(emptyForbiddenIsAttemptLimit && (data == null || data == '')))) {
return MfaRequestFailure.attemptLimit;
}
return null;
}

MfaRequestException _mfaRequestException(
String operation,
DioException error, {
Map<int, MfaRequestFailure> statusFailures = const {},
bool classifyResponse = false,
bool emptyForbiddenIsAttemptLimit = false,
}) {
final statusCode = error.response?.statusCode;
final networkError =
Expand All @@ -86,7 +122,15 @@ MfaRequestException _mfaRequestException(
statusCode: statusCode,
failure: networkError
? MfaRequestFailure.network
: statusFailures[statusCode] ?? MfaRequestFailure.server,
: (classifyResponse
? _mfaResponseFailure(
error,
emptyForbiddenIsAttemptLimit:
emptyForbiddenIsAttemptLimit,
)
: null) ??
statusFailures[statusCode] ??
MfaRequestFailure.server,
);
}

Expand Down Expand Up @@ -404,10 +448,10 @@ class ProxyApi {
if (response.statusCode == 428) return null;
return FinishMfaResponse.fromJson(response.data);
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
if (e.response?.statusCode == 401 && _mfaResponseFailure(e) == null) {
throw const MfaCodeRejectedException();
}
throw _mfaRequestException('MFA finish', e);
throw _mfaRequestException('MFA finish', e, classifyResponse: true);
} catch (e) {
talker.error('Invalid MFA finish response: ${e.runtimeType}');
throw const FormatException('Invalid MFA finish response');
Expand All @@ -429,13 +473,14 @@ class ProxyApi {
final response = await _dio.postUri(endpoint, data: data.toJson());
return wire.MfaFlowStepFinishResponse.fromJson(response.data);
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
if (e.response?.statusCode == 401 && _mfaResponseFailure(e) == null) {
throw const MfaCodeRejectedException();
}
throw _mfaRequestException(
'MFA flow step finish',
e,
statusFailures: const {403: MfaRequestFailure.attemptLimit},
classifyResponse: true,
emptyForbiddenIsAttemptLimit: true,
);
} catch (e) {
talker.error('Invalid MFA flow step finish response: ${e.runtimeType}');
Expand Down
24 changes: 24 additions & 0 deletions client/lib/utils/error_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ class ErrorHandler {
"This device does not meet the location's security requirements.",
MfaRequestFailure.attemptLimit =>
"Too many failed attempts. Start connecting again.",
MfaRequestFailure.sessionExpired => _statusMessage(e.statusCode),
MfaRequestFailure.staleAttempt => _statusMessage(e.statusCode),
MfaRequestFailure.notReady =>
"Verification can't start right now. Try again in a moment.",
MfaRequestFailure.server => _statusMessage(e.statusCode),
Expand All @@ -61,6 +63,28 @@ class ErrorHandler {
return s;
}

static String getOpenIdMfaError(Object e) {
if (e is DioException && e.response == null) {
return getHumanReadableError(e);
}
if (e is! MfaRequestException) {
return "Authentication failed. Please try again.";
}
return switch (e.failure) {
MfaRequestFailure.network => _connectionErrorMessage,
MfaRequestFailure.policyDenied =>
"This device does not meet the location's security requirements.",
MfaRequestFailure.attemptLimit =>
"Too many failed MFA attempts. Please try connecting again.",
MfaRequestFailure.sessionExpired => "Session expired. Please try again.",
MfaRequestFailure.staleAttempt =>
"Authentication request could not be started. Please try again.",
MfaRequestFailure.notReady =>
"Verification can't start right now. Try again in a moment.",
MfaRequestFailure.server => "Authentication failed. Please try again.",
};
}

static const _connectionErrorMessage =
"Unable to connect to the server. Please check your internet connection.";

Expand Down
73 changes: 70 additions & 3 deletions client/test/enterprise/openid_mfa_waiting_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@ import 'package:mobile/data/mfa/mfa_flow.dart';
import 'package:mobile/data/mfa/mfa_transport.dart';
import 'package:mobile/enterprise/postures.dart';
import 'package:mobile/enterprise/screens/mfa/openid_mfa_waiting_screen.dart';
import 'package:mobile/open/api.dart';
import 'package:mobile/open/screens/mfa/mfa_step_chrome.dart';

class _PollingTransport implements MfaTransport {
final Queue<MfaFinishResult> results;
final Object? finishError;
final stepAttemptIds = <String?>[];
final credentials = <MfaCredential?>[];

_PollingTransport(Iterable<MfaFinishResult> results)
: results = Queue.of(results);
_PollingTransport(
Iterable<MfaFinishResult> results, {
this.finishError,
}) : results = Queue.of(results);

@override
Future<MfaSessionStart> start({
Expand All @@ -42,6 +46,7 @@ class _PollingTransport implements MfaTransport {
}) async {
stepAttemptIds.add(stepAttemptId);
credentials.add(credential);
if (finishError != null) throw finishError!;
return results.removeFirst();
}
}
Expand All @@ -50,6 +55,7 @@ class _Host implements MfaStepHost {
@override
final MfaFlowController controller;
final progress = Completer<MfaStepProgress>();
final failure = Completer<String>();

_Host(this.controller);

Expand All @@ -64,7 +70,7 @@ class _Host implements MfaStepHost {
String? logMessage,
Object? error,
}) {
if (!progress.isCompleted) progress.completeError(StateError(message));
if (!failure.isCompleted) failure.complete(message);
}

@override
Expand All @@ -75,6 +81,67 @@ class _Host implements MfaStepHost {
}

void main() {
testWidgets('reports a safe OIDC failure through the host', (tester) async {
final transport = _PollingTransport(
[],
finishError: MfaRequestException(
'MFA flow step finish',
statusCode: 400,
failure: MfaRequestFailure.sessionExpired,
),
);
final controller = MfaFlowController(
transport: transport,
plan: [MfaMethod.openid],
devicePubkey: 'device-pubkey',
networkId: 11,
);
await controller.startStep();
final host = _Host(controller);

await tester.pumpWidget(
ProviderScope(
child: MaterialApp(home: OpenIdMfaWaitingScreen(host: host)),
),
);
await tester.pumpAndSettle();

expect(await host.failure.future, 'Session expired. Please try again.');
});

testWidgets('hides unknown OIDC response details from the host', (
tester,
) async {
final transport = _PollingTransport(
[],
finishError: MfaRequestException(
'MFA flow step finish',
statusCode: 400,
failure: MfaRequestFailure.server,
),
);
final controller = MfaFlowController(
transport: transport,
plan: [MfaMethod.openid],
devicePubkey: 'device-pubkey',
networkId: 11,
);
await controller.startStep();
final host = _Host(controller);

await tester.pumpWidget(
ProviderScope(
child: MaterialApp(home: OpenIdMfaWaitingScreen(host: host)),
),
);
await tester.pumpAndSettle();

expect(
await host.failure.future,
'Authentication failed. Please try again.',
);
});

testWidgets('polls without a proof until the OIDC attempt completes', (
tester,
) async {
Expand Down
130 changes: 130 additions & 0 deletions client/test/mfa/mfa_code_screen_finish_error_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:material_ui/material_ui.dart';
import 'package:mobile/data/db/enums.dart';
import 'package:mobile/data/mfa/mfa_flow.dart';
import 'package:mobile/data/mfa/mfa_transport.dart';
import 'package:mobile/enterprise/postures.dart';
import 'package:mobile/open/api.dart';
import 'package:mobile/open/screens/mfa/mfa_code_screen.dart';
import 'package:mobile/open/screens/mfa/mfa_step_chrome.dart';
import 'package:mobile/open/widgets/toaster/toast_manager.dart';

class _FailingTransport implements MfaTransport {
final MfaRequestFailure failure;

const _FailingTransport(this.failure);

@override
Future<MfaSessionStart> start({
required String devicePubkey,
required int networkId,
required List<MfaMethod> plan,
DevicePostureData? postureData,
}) async => const MfaSessionStart(
token: 'flow-token',
firstStep: MfaStepChallenge(stepAttemptId: 'attempt-1'),
);

@override
Future<MfaStepChallenge> startStep(String token, MfaMethod method) async =>
const MfaStepChallenge(stepAttemptId: 'attempt-1');

@override
Future<MfaFinishResult> finish({
required String token,
required String? stepAttemptId,
required MfaCredential? credential,
}) async {
throw MfaRequestException(
'MFA flow step finish',
statusCode: 403,
failure: failure,
);
}
}

class _TestHost implements MfaStepHost {
@override
final MfaFlowController controller;
String? failureMessage;

_TestHost(MfaRequestFailure failure)
: controller = MfaFlowController(
transport: _FailingTransport(failure),
plan: const [MfaMethod.totp],
devicePubkey: 'device-key',
networkId: 11,
);

@override
void reportFailure({
required String message,
String? logMessage,
Object? error,
}) {
failureMessage = message;
}

@override
void reportProgress(MfaStepProgress progress) {}

@override
void abort() {}
}

void main() {
Future<(String?, ToastData?)> submitCode(
WidgetTester tester,
MfaRequestFailure failure,
) async {
final host = _TestHost(failure);
final container = ProviderContainer();
addTearDown(container.dispose);
await host.controller.startStep();
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: MfaCodeScreen(
host: host,
title: 'Verification',
description: 'Enter your code.',
fieldLabel: 'Code',
logLabel: 'MFA',
),
),
),
);
await tester.enterText(find.byType(TextField), '123456');
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
final toast = container.read(toastManagerProvider);
container.read(toastManagerProvider.notifier).remove();
return (host.failureMessage, toast);
}

testWidgets('an exhausted finish is reported with attempt-limit copy', (
tester,
) async {
final (failureMessage, toast) = await submitCode(
tester,
MfaRequestFailure.attemptLimit,
);

expect(failureMessage, 'Too many failed attempts. Start connecting again.');
expect(toast, isNull);
});

testWidgets('an unrelated finish 403 remains a generic screen error', (
tester,
) async {
final (failureMessage, toast) = await submitCode(
tester,
MfaRequestFailure.server,
);

expect(failureMessage, isNull);
expect(toast?.message, 'Access forbidden.');
});
}
Loading
Loading