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
37 changes: 37 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,40 @@ for (final result in results) {

If you were already on `createSignedUrlsResult`, drop the `Result` suffix from the call.

### Confirming an email or phone change emits `userUpdated`

Confirming an email or phone change used to emit `AuthChangeEvent.signedIn`, which made it
indistinguishable from an actual sign-in. It now emits `AuthChangeEvent.userUpdated`, the same event
that `updateUser()` emits when the change is requested. This applies to every way the change can be
Comment thread
coderabbitai[bot] marked this conversation as resolved.
confirmed:

| Confirmation | Before | After |
| --- | --- | --- |
| `verifyOTP()` with `OtpType.emailChange` or `OtpType.phoneChange` | `signedIn` | `userUpdated` |
| `getSessionFromUrl()` with an implicit `type=email_change` link | `signedIn` | `userUpdated` |
| `exchangeCodeForSession()` for a PKCE code from an email change | `signedIn` | `userUpdated` |

The session is still saved and `currentSession` still updates, only the event differs. Because this
is a runtime change and not a compile error, check any `onAuthStateChange` listener that navigates
or fetches on `signedIn` and expects the email-change confirmation to reach it:

```dart
// Before
supabase.auth.onAuthStateChange.listen((data) {
if (data.event == AuthChangeEvent.signedIn) {
// Ran both on sign-in and after an email change was confirmed.
}
});

// After
supabase.auth.onAuthStateChange.listen((data) {
if (data.event == AuthChangeEvent.signedIn) {
// Only runs on an actual sign-in.
} else if (data.event == AuthChangeEvent.userUpdated) {
// Runs when the user record changed, including a confirmed email change.
}
});
```

For the PKCE case, `AuthSessionUrlResponse.redirectType` is `'userUpdated'` instead of `null`, so
you can also branch on the response of `exchangeCodeForSession()` directly.
52 changes: 36 additions & 16 deletions packages/gotrue/lib/src/gotrue_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,11 @@ class GoTrueClient {
}

/// Verifies the PKCE code verifier and retrieves a session.
///
/// Emits [AuthChangeEvent.signedIn], unless the code was issued by
/// [resetPasswordForEmail], which emits [AuthChangeEvent.passwordRecovery],
/// or by an email change through [updateUser], which emits
/// [AuthChangeEvent.userUpdated].
Future<AuthSessionUrlResponse> exchangeCodeForSession(String authCode) async {
assert(
_asyncStorage != null,
Expand Down Expand Up @@ -464,11 +469,9 @@ class GoTrueClient {

final session = authSessionUrlResponse.session;
_saveSession(session);
if (redirectType == AuthChangeEvent.passwordRecovery) {
notifyAllSubscribers(AuthChangeEvent.passwordRecovery);
} else {
notifyAllSubscribers(AuthChangeEvent.signedIn);
}
// The event name stored with the code verifier is the event to emit, so a
// code that was issued without one is a plain sign in.
notifyAllSubscribers(redirectType ?? AuthChangeEvent.signedIn);

return authSessionUrlResponse;
}
Expand Down Expand Up @@ -675,6 +678,11 @@ class GoTrueClient {
/// [token] is the token that user was sent to their mobile phone
///
/// [tokenHash] is the token used in an email link
///
/// Once a session is issued, [AuthChangeEvent.signedIn] is emitted, except
/// for [OtpType.recovery], which emits [AuthChangeEvent.passwordRecovery],
/// and [OtpType.emailChange] and [OtpType.phoneChange], which emit
/// [AuthChangeEvent.userUpdated].
Future<AuthResponse> verifyOTP({
String? email,
String? phone,
Expand Down Expand Up @@ -734,11 +742,16 @@ class GoTrueClient {
final session = authResponse.session;
if (session != null) {
_saveSession(session);
notifyAllSubscribers(
type == OtpType.recovery
? AuthChangeEvent.passwordRecovery
: AuthChangeEvent.signedIn,
);
notifyAllSubscribers(switch (type) {
OtpType.recovery => AuthChangeEvent.passwordRecovery,
OtpType.emailChange ||
OtpType.phoneChange => AuthChangeEvent.userUpdated,
OtpType.sms ||
OtpType.signup ||
OtpType.invite ||
OtpType.magiclink ||
OtpType.email => AuthChangeEvent.signedIn,
});
}

return authResponse;
Expand Down Expand Up @@ -918,7 +931,9 @@ class GoTrueClient {
}

final codeChallenge = attributes.email != null
? await _generatePKCECodeChallenge()
? await _generatePKCECodeChallenge(
storageEventName: AuthChangeEvent.userUpdated.name,
)
: null;

final body = {
Expand Down Expand Up @@ -1003,6 +1018,11 @@ class GoTrueClient {
}

/// Gets the session data from a magic link or oauth2 callback URL
///
/// When the session is stored, [AuthChangeEvent.signedIn] is emitted, except
/// for a password recovery link, which emits
/// [AuthChangeEvent.passwordRecovery], and an email change confirmation link,
/// which emits [AuthChangeEvent.userUpdated].
Future<AuthSessionUrlResponse> getSessionFromUrl(
Uri originUrl, {
bool storeSession = true,
Expand Down Expand Up @@ -1078,11 +1098,11 @@ class GoTrueClient {

if (storeSession == true) {
_saveSession(session);
if (redirectType == 'recovery') {
notifyAllSubscribers(AuthChangeEvent.passwordRecovery);
} else {
notifyAllSubscribers(AuthChangeEvent.signedIn);
}
notifyAllSubscribers(switch (redirectType) {
'recovery' => AuthChangeEvent.passwordRecovery,
'email_change' => AuthChangeEvent.userUpdated,
_ => AuthChangeEvent.signedIn,
});
}

return AuthSessionUrlResponse(session: session, redirectType: redirectType);
Expand Down
24 changes: 23 additions & 1 deletion packages/gotrue/test/client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,7 @@ void main() {
expect(response.session.user.email, 'new@email.com');
expect(response.redirectType, 'email_change');
expect(pkceClient.currentUser?.email, 'new@email.com');
expect(await emittedEvent, AuthChangeEvent.signedIn);
expect(await emittedEvent, AuthChangeEvent.userUpdated);
},
);

Expand Down Expand Up @@ -825,6 +825,15 @@ void main() {
autoRefreshToken: false,
);

// Collected from before the sign in so the event emitted by the
// exchange is identified by its position, rather than by whatever the
// stream replays to a late subscriber.
final events = <AuthChangeEvent>[];
final subscription = pkceClient.onAuthStateChange.listen(
(state) => events.add(state.event),
onError: (_) {},
);

await pkceClient.signInWithPassword(
email: email1,
password: password,
Expand All @@ -841,6 +850,19 @@ void main() {

expect(exchanged.session.user.email, updatedEmail);
expect(exchanged.session.accessToken, isNotEmpty);
expect(exchanged.redirectType, AuthChangeEvent.userUpdated.name);

await pumpEventQueue();
expect(events, [
// signInWithPassword
AuthChangeEvent.signedIn,
// updateUser requested the change
AuthChangeEvent.userUpdated,
// exchangeCodeForSession confirmed it
AuthChangeEvent.userUpdated,
]);

await subscription.cancel();
},
);
});
Expand Down
87 changes: 87 additions & 0 deletions packages/gotrue/test/otp_mock_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:gotrue/gotrue.dart';
import 'package:gotrue/src/constants.dart' show Constants;
import 'package:test/test.dart';

import 'mocks/otp_mock_client.dart';
Expand Down Expand Up @@ -124,6 +125,59 @@ void main() {
expect(client.currentUser, isNotNull);
});

test('verifyOTP() emits signedIn for a sign in type', () async {
final emittedEvent = _nextEvent(client);

await client.verifyOTP(
email: testEmail,
token: '123456',
type: OtpType.email,
);

expect(await emittedEvent, AuthChangeEvent.signedIn);
});

test('verifyOTP() emits passwordRecovery for the recovery type', () async {
final emittedEvent = _nextEvent(client);

await client.verifyOTP(
email: testEmail,
token: '123456',
type: OtpType.recovery,
);

expect(await emittedEvent, AuthChangeEvent.passwordRecovery);
});

// Regression test for
// https://github.com/supabase/supabase-flutter/issues/1398
//
// Confirming an email change is not a sign in, so it has to be
// distinguishable from one.
test('verifyOTP() emits userUpdated for an email change', () async {
final emittedEvent = _nextEvent(client);

await client.verifyOTP(
email: testEmail,
token: '123456',
type: OtpType.emailChange,
);

expect(await emittedEvent, AuthChangeEvent.userUpdated);
});

test('verifyOTP() emits userUpdated for a phone change', () async {
final emittedEvent = _nextEvent(client);

await client.verifyOTP(
phone: testPhone,
token: '123456',
type: OtpType.phoneChange,
);

expect(await emittedEvent, AuthChangeEvent.userUpdated);
});

test('verifyOTP() with tokenHash', () async {
final response = await client.verifyOTP(
tokenHash: 'mock-token-hash',
Expand Down Expand Up @@ -350,6 +404,33 @@ void main() {
},
);

// Regression test for
// https://github.com/supabase/supabase-flutter/issues/1398
//
// The event name is persisted alongside the code verifier so that
// exchangeCodeForSession knows the code came from an email change and can
// emit userUpdated instead of signedIn.
test(
'updateUser() with email stores the userUpdated event name',
() async {
await client.verifyOTP(
phone: testPhone,
token: '123456',
type: OtpType.sms,
);

await client.updateUser(UserAttributes(email: testEmail));

final storedVerifier = await asyncStorage.getItem(
key: '${Constants.defaultStorageKey}-code-verifier',
);
expect(
storedVerifier?.split('/').last,
AuthChangeEvent.userUpdated.name,
);
},
);

test('updateUser() without email omits code challenge', () async {
await client.verifyOTP(
phone: testPhone,
Expand Down Expand Up @@ -786,3 +867,9 @@ void main() {
);
});
}

/// The first auth event of [client] that isn't the initial session.
Future<AuthChangeEvent> _nextEvent(GoTrueClient client) => client
.onAuthStateChange
.firstWhere((state) => state.event != AuthChangeEvent.initialSession)
.then((state) => state.event);
10 changes: 6 additions & 4 deletions packages/supabase_flutter/test/deep_link_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ void main() {

group('Deep Link with implicit token while PKCE flow is configured', () {
late final GetUserHttpClient getUserHttpClient;
late final Future<AuthState> signedInState;
late final Future<AuthState> userUpdatedState;

setUp(() async {
getUserHttpClient = GetUserHttpClient('new@email.com');
Expand All @@ -85,14 +85,16 @@ void main() {
),
);

signedInState = Supabase.instance.client.auth.onAuthStateChange
.firstWhere((state) => state.event == AuthChangeEvent.signedIn)
// The link confirms an email change, so it emits `userUpdated` rather
// than `signedIn`.
userUpdatedState = Supabase.instance.client.auth.onAuthStateChange
.firstWhere((state) => state.event == AuthChangeEvent.userUpdated)
.timeout(const Duration(seconds: 5));
});

test('Implicit token in the fragment triggers `getSessionFromUrl` and '
'updates the current user', () async {
final state = await signedInState;
final state = await userUpdatedState;
expect(state.session?.user.email, 'new@email.com');
expect(getUserHttpClient.requestCount, 1);
expect(getUserHttpClient.lastRequestUrl?.path, endsWith('/user'));
Expand Down
Loading