Skip to content
Open
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
15 changes: 13 additions & 2 deletions packages/clerk_auth/lib/src/clerk_auth/auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -550,10 +550,21 @@ class Auth {
return;
}

// Ensure we have a signIn object for the current identifier
// Ensure we have a signIn object for the current identifier.
//
// The comparison is case-insensitive because the back end normalises
// identifiers (an email address is stored lower-cased) while the UI sends
// back exactly what the user typed. A case-sensitive comparison therefore
// treats `Person@example.com` and the stored `person@example.com` as two
// different users, discards the in-flight [SignIn] and creates a new one on
// every submission -- which re-prepares the first factor, invalidating the
// code already sent, and then checks the user's code against a verification
// it does not belong to. The result is a permanent `form_code_incorrect`
// for anyone whose identifier differs from its normalised form by case.
if (client.signIn == null ||
(identifier?.orNullIfEmpty is String &&
identifier != client.signIn!.identifier)) {
identifier!.toLowerCase() !=
client.signIn!.identifier?.toLowerCase())) {
// if password and identifier been presented, we can immediately attempt
// a sign in; if null they will be ignored
await _api
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import 'package:clerk_auth/clerk_auth.dart';

import '../../test_helpers.dart';

void main() {
group('attemptSignIn identifier comparison', () {
late MockHttpService mockHttp;
late Auth auth;

// The back end normalises an email identifier to lower case; the sign-in UI
// sends back whatever the user typed. These two therefore routinely differ.
const typedIdentifier = 'Person@example.com';
const storedIdentifier = 'person@example.com';

Map<String, dynamic> signInWithPreparedFirstFactor({
String status = 'needs_first_factor',
}) {
return {
'object': 'sign_in',
'id': 'signin_123',
'status': status,
'identifier': storedIdentifier,
'supported_identifiers': ['email_address'],
'supported_first_factors': [
{'strategy': 'email_code', 'email_address_id': 'idn_123'},
],
'supported_second_factors': [],
'first_factor_verification': {
'object': 'verification',
'status': 'unverified',
'strategy': 'email_code',
'attempts': 0,
'expire_at': DateTime.now()
.add(const Duration(minutes: 10))
.millisecondsSinceEpoch,
},
'second_factor_verification': null,
'created_session_id': null,
'abandon_at':
DateTime.now().add(const Duration(days: 1)).millisecondsSinceEpoch,
};
}

bool isSignInCreation(MockHttpCall call) =>
call.uri.path.endsWith('/client/sign_ins');

setUp(() async {
mockHttp = MockHttpService();
auth = Auth(
config: TestAuthConfig(
publishableKey: TestAuthConfig.kPublishableKey,
httpService: mockHttp,
),
);
mockHttp.addClientResponse();
mockHttp.addEnvironmentResponse();
await auth.initialize();

// A sign-in is under way and its first factor has been prepared, i.e. a
// code has been emailed to the user.
mockHttp.addClientResponse(signIn: signInWithPreparedFirstFactor());
await auth.attemptSignIn(
strategy: Strategy.emailCode,
identifier: typedIdentifier,
);
});

tearDown(() {
auth.terminate();
});

test('the back end has normalised the identifier we sent', () {
expect(auth.client.signIn?.identifier, equals(storedIdentifier));
});

test(
'submitting a code does not discard a sign-in whose identifier differs '
'only by case',
() async {
final callsBefore = mockHttp.calls.length;

mockHttp.addClientResponse(
signIn: signInWithPreparedFirstFactor(status: 'complete'),
);
await auth.attemptSignIn(
strategy: Strategy.emailCode,
identifier: typedIdentifier,
code: '424242',
);

// Creating a second SignIn here re-prepares the first factor, which
// invalidates the code already sent to the user, and then checks the
// code they typed against a verification it does not belong to. The
// sign-in fails with form_code_incorrect, and because every retry
// repeats the cycle it can never succeed.
final creations =
mockHttp.calls.skip(callsBefore).where(isSignInCreation);
expect(creations, isEmpty);
},
);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:clerk_flutter/src/widgets/ui/clerk_phone_number_form_field.dart'
import 'package:clerk_flutter/src/widgets/ui/clerk_text_form_field.dart';
import 'package:clerk_flutter/src/widgets/ui/closeable.dart';
import 'package:clerk_flutter/src/widgets/ui/common.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:phone_input/phone_input_package.dart';

Expand Down Expand Up @@ -166,6 +167,20 @@ class _ClerkIdentifierInputState extends State<ClerkIdentifierInput> {
onChanged: (ident) => _onChanged(Identifier(ident)),
onSubmit: _onSubmit,
focusNode: _emailFocusNode,
// An email address is not prose. Left to the platform defaults,
// iOS autocorrect rewrites a correctly typed address as it is
// entered -- capitalising `derek@...` to `Derek@...` because the
// local part matches a proper noun in its dictionary. The
// identifier then no longer matches its normalised form on the
// back end. The hints also let the platform offer saved
// credentials, which it cannot do without them.
keyboardType: TextInputType.emailAddress,
autocorrect: false,
textCapitalization: TextCapitalization.none,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
trailing: hasPhoneStrategies
? _SwapIdentifierButton(
strategies: phoneStrategies,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,25 @@ class ClerkTextFormField extends StatelessWidget {
this.validator,
this.trailing,
this.hint,
this.keyboardType,
this.autocorrect = true,
this.textCapitalization = TextCapitalization.none,
this.autofillHints,
});

/// The keyboard to present for this field.
final TextInputType? keyboardType;

/// Whether to enable platform autocorrection. Must be `false` for anything
/// that is not prose — an email address or username is corrupted by it.
final bool autocorrect;

/// Whether the platform should capitalise input for this field.
final TextCapitalization textCapitalization;

/// Autofill hints, so the platform can offer saved credentials.
final List<String>? autofillHints;

/// Report changes back to calling widget
final ValueChanged<String>? onChanged;

Expand Down Expand Up @@ -95,6 +112,10 @@ class ClerkTextFormField extends StatelessWidget {
focusNode: focusNode,
inputFormatter: inputFormatter,
hint: hint,
keyboardType: keyboardType,
autocorrect: autocorrect,
textCapitalization: textCapitalization,
autofillHints: autofillHints,
),
],
);
Expand All @@ -113,6 +134,10 @@ class _TextField extends StatefulWidget {
this.inputFormatter,
this.focusNode,
this.hint,
this.keyboardType,
this.autocorrect = true,
this.textCapitalization = TextCapitalization.none,
this.autofillHints,
});

final ValueChanged<String>? onChanged;
Expand All @@ -125,6 +150,10 @@ class _TextField extends StatefulWidget {
final TextInputFormatter? inputFormatter;
final String? initial;
final String? hint;
final TextInputType? keyboardType;
final bool autocorrect;
final TextCapitalization textCapitalization;
final List<String>? autofillHints;

List<TextInputFormatter>? get inputFormatters => switch (inputFormatter) {
TextInputFormatter formatter => [formatter],
Expand Down Expand Up @@ -160,6 +189,10 @@ class _TextFieldState extends State<_TextField> {
),
onChanged: widget.onChanged,
onFieldSubmitted: widget.onSubmit,
keyboardType: widget.keyboardType,
autocorrect: widget.autocorrect,
textCapitalization: widget.textCapitalization,
autofillHints: widget.autofillHints,
obscureText: _obscure,
obscuringCharacter: '\u25CF' /* Unicode: Black Circle */,
validator: (text) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import 'package:clerk_auth/clerk_auth.dart' as clerk;
import 'package:clerk_flutter/clerk_flutter.dart';
import 'package:clerk_flutter/src/widgets/ui/clerk_identifier_input.dart';
import 'package:clerk_flutter/src/widgets/ui/clerk_text_form_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

import '../../test_support/test_support.dart';

class _IdentifierInputUnderTest extends StatelessWidget {
const _IdentifierInputUnderTest();

@override
Widget build(BuildContext context) {
return ClerkIdentifierInput(
onChanged: (_) {},
strategies: const [clerk.Strategy.emailCode],
);
}
}

void main() {
group('ClerkIdentifierInput email field', () {
late ClerkAuthState authState;

setUp(() async {
authState = await createSignedOutAuthState();
});

tearDown(() {
authState.terminate();
});

testWidgets(
'is configured so the platform cannot rewrite the address',
(tester) async {
await tester.pumpWidget(
TestClerkAuthWrapper(
authState: authState,
child: const Material(
child: SingleChildScrollView(
child: _IdentifierInputUnderTest(),
),
),
),
);
await tester.pumpAndSettle();

final field = tester.widget<ClerkTextFormField>(
find.byKey(const Key('identifier')),
);

// An email address is not prose. With autocorrect left on, iOS rewrites
// a correctly typed address as it is entered -- capitalising a local
// part that matches a proper noun in its dictionary -- and the
// identifier then differs by case from the form the back end stores.
expect(field.autocorrect, isFalse);
expect(field.textCapitalization, TextCapitalization.none);

// Without these the platform shows a prose keyboard with no `@`, and
// cannot offer saved credentials.
expect(field.keyboardType, TextInputType.emailAddress);
expect(field.autofillHints, contains(AutofillHints.email));
},
);
});
}
Loading