Skip to content

Commit 8f473f1

Browse files
authored
feat: Broadcast auth events to other tabs on web (#1005)
* feat: broadcast auth events on web * refactor: consolidate types in one file * fix: add dispose to auth client * refactor: use js object for messaging * style: remove unused userDeleted event * refactor: remove deprecation of supabasePersistSessionKey * fix: store session in client * fix: allow removing session on broadcast and add bool to AuthState * fix: catch session being null * fix: improvements from code review * fix: remove broadcastSession from constructor
1 parent 773b7de commit 8f473f1

File tree

10 files changed

+150
-19
lines changed

10 files changed

+150
-19
lines changed

packages/gotrue/lib/gotrue.dart

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ export 'src/types/auth_response.dart' hide ToSnakeCase;
99
export 'src/types/auth_state.dart';
1010
export 'src/types/gotrue_async_storage.dart';
1111
export 'src/types/mfa.dart';
12-
export 'src/types/o_auth_provider.dart';
13-
export 'src/types/oauth_flow_type.dart';
12+
export 'src/types/types.dart';
1413
export 'src/types/session.dart';
1514
export 'src/types/user.dart';
1615
export 'src/types/user_attributes.dart';
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import 'package:gotrue/src/types/types.dart';
2+
3+
/// Stub implementation of [BroadcastChannel] for platforms that don't support it.
4+
BroadcastChannel getBroadcastChannel(String broadcastKey) {
5+
throw UnimplementedError();
6+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import 'dart:convert';
2+
import 'dart:html' as html;
3+
import 'dart:js_util' as js_util;
4+
5+
import 'package:gotrue/src/types/types.dart';
6+
7+
BroadcastChannel getBroadcastChannel(String broadcastKey) {
8+
final broadcast = html.BroadcastChannel(broadcastKey);
9+
return (
10+
onMessage: broadcast.onMessage.map((event) {
11+
final dataMap = js_util.dartify(event.data);
12+
13+
// some parts have the wrong map type. This is an easy workaround and
14+
// should be efficient enough for the small session and user data
15+
return json.decode(json.encode(dataMap));
16+
}),
17+
postMessage: (message) {
18+
final jsMessage = js_util.jsify(message);
19+
broadcast.postMessage(jsMessage);
20+
},
21+
close: broadcast.close,
22+
);
23+
}

packages/gotrue/lib/src/constants.dart

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,19 @@ class ApiVersions {
3434
}
3535

3636
enum AuthChangeEvent {
37-
initialSession,
38-
passwordRecovery,
39-
signedIn,
40-
signedOut,
41-
tokenRefreshed,
42-
userUpdated,
43-
userDeleted,
44-
mfaChallengeVerified,
37+
initialSession('INITIAL_SESSION'),
38+
passwordRecovery('PASSWORD_RECOVERY'),
39+
signedIn('SIGNED_IN'),
40+
signedOut('SIGNED_OUT'),
41+
tokenRefreshed('TOKEN_REFRESHED'),
42+
userUpdated('USER_UPDATED'),
43+
44+
@Deprecated('Was never in use and might be removed in the future.')
45+
userDeleted(''),
46+
mfaChallengeVerified('MFA_CHALLENGE_VERIFIED');
47+
48+
final String jsName;
49+
const AuthChangeEvent(this.jsName);
4550
}
4651

4752
extension AuthChangeEventExtended on AuthChangeEvent {

packages/gotrue/lib/src/gotrue_client.dart

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import 'package:meta/meta.dart';
1515
import 'package:retry/retry.dart';
1616
import 'package:rxdart/subjects.dart';
1717

18+
import 'broadcast_stub.dart' if (dart.library.html) './broadcast_web.dart'
19+
as web;
20+
1821
part 'gotrue_mfa_api.dart';
1922

2023
/// {@template gotrue_client}
@@ -84,6 +87,11 @@ class GoTrueClient {
8487

8588
final AuthFlowType _flowType;
8689

90+
/// Proxy to the web BroadcastChannel API. Should be null on non-web platforms.
91+
BroadcastChannel? _broadcastChannel;
92+
93+
StreamSubscription? _broadcastChannelSubscription;
94+
8795
/// {@macro gotrue_client}
8896
GoTrueClient({
8997
String? url,
@@ -116,6 +124,8 @@ class GoTrueClient {
116124
if (_autoRefreshToken) {
117125
startAutoRefresh();
118126
}
127+
128+
_mayStartBroadcastChannel();
119129
}
120130

121131
/// Getter for the headers
@@ -1128,6 +1138,63 @@ class GoTrueClient {
11281138
_currentUser = null;
11291139
}
11301140

1141+
void _mayStartBroadcastChannel() {
1142+
if (const bool.fromEnvironment('dart.library.html')) {
1143+
// Used by the js library as well
1144+
final broadcastKey =
1145+
"sb-${Uri.parse(_url).host.split(".").first}-auth-token";
1146+
1147+
assert(_broadcastChannel == null,
1148+
'Broadcast channel should not be started more than once.');
1149+
try {
1150+
_broadcastChannel = web.getBroadcastChannel(broadcastKey);
1151+
_broadcastChannelSubscription =
1152+
_broadcastChannel?.onMessage.listen((messageEvent) {
1153+
final rawEvent = messageEvent['event'];
1154+
final event = switch (rawEvent) {
1155+
// This library sends the js name of the event to be comptabile with
1156+
// the js library, so we need to convert it back to the dart name
1157+
'INITIAL_SESSION' => AuthChangeEvent.initialSession,
1158+
'PASSWORD_RECOVERY' => AuthChangeEvent.passwordRecovery,
1159+
'SIGNED_IN' => AuthChangeEvent.signedIn,
1160+
'SIGNED_OUT' => AuthChangeEvent.signedOut,
1161+
'TOKEN_REFRESHED' => AuthChangeEvent.tokenRefreshed,
1162+
'USER_UPDATED' => AuthChangeEvent.userUpdated,
1163+
'MFA_CHALLENGE_VERIFIED' => AuthChangeEvent.mfaChallengeVerified,
1164+
// This case should never happen though
1165+
_ => AuthChangeEvent.values
1166+
.firstWhereOrNull((event) => event.name == rawEvent),
1167+
};
1168+
1169+
if (event != null) {
1170+
Session? session;
1171+
if (messageEvent['session'] != null) {
1172+
session = Session.fromJson(messageEvent['session']);
1173+
}
1174+
if (session != null) {
1175+
_saveSession(session);
1176+
} else {
1177+
_removeSession();
1178+
}
1179+
notifyAllSubscribers(event, session: session, broadcast: false);
1180+
}
1181+
});
1182+
} catch (e) {
1183+
// Ignoring
1184+
}
1185+
}
1186+
}
1187+
1188+
@mustCallSuper
1189+
void dispose() {
1190+
_onAuthStateChangeController.close();
1191+
_onAuthStateChangeControllerSync.close();
1192+
_broadcastChannel?.close();
1193+
_broadcastChannelSubscription?.cancel();
1194+
_refreshTokenCompleter?.completeError(AuthException('Disposed'));
1195+
_autoRefreshTicker?.cancel();
1196+
}
1197+
11311198
/// Generates a new JWT.
11321199
///
11331200
/// To prevent multiple simultaneous requests it catches an already ongoing request by using the global [_refreshTokenCompleter].
@@ -1181,9 +1248,23 @@ class GoTrueClient {
11811248
}
11821249

11831250
/// For internal use only.
1251+
///
1252+
/// [broadcast] is used to determine if the event should be broadcasted to
1253+
/// other tabs.
11841254
@internal
1185-
void notifyAllSubscribers(AuthChangeEvent event) {
1186-
final state = AuthState(event, currentSession);
1255+
void notifyAllSubscribers(
1256+
AuthChangeEvent event, {
1257+
Session? session,
1258+
bool broadcast = true,
1259+
}) {
1260+
session ??= currentSession;
1261+
if (broadcast && event != AuthChangeEvent.initialSession) {
1262+
_broadcastChannel?.postMessage({
1263+
'event': event.jsName,
1264+
'session': session?.toJson(),
1265+
});
1266+
}
1267+
final state = AuthState(event, session, fromBroadcast: !broadcast);
11871268
_onAuthStateChangeController.add(state);
11881269
_onAuthStateChangeControllerSync.add(state);
11891270
}

packages/gotrue/lib/src/types/auth_state.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,14 @@ class AuthState {
55
final AuthChangeEvent event;
66
final Session? session;
77

8-
AuthState(this.event, this.session);
8+
/// Whether this state was broadcasted via `html.ChannelBroadcast` on web from
9+
/// another tab or window.
10+
final bool fromBroadcast;
11+
12+
const AuthState(this.event, this.session, {this.fromBroadcast = false});
13+
14+
@override
15+
String toString() {
16+
return 'AuthState{event: $event, session: $session, fromBroadcast: $fromBroadcast}';
17+
}
918
}

packages/gotrue/lib/src/types/oauth_flow_type.dart

Lines changed: 0 additions & 4 deletions
This file was deleted.

packages/gotrue/lib/src/types/o_auth_provider.dart renamed to packages/gotrue/lib/src/types/types.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
typedef BroadcastChannel = ({
2+
Stream<Map<String, dynamic>> onMessage,
3+
void Function(Map) postMessage,
4+
void Function() close,
5+
});
6+
7+
enum AuthFlowType {
8+
implicit,
9+
pkce,
10+
}
11+
112
enum OAuthProvider {
213
apple,
314
azure,

packages/supabase/lib/src/supabase_client.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ class SupabaseClient {
250250
Future<void> dispose() async {
251251
await _authStateSubscription?.cancel();
252252
await _isolate.dispose();
253+
auth.dispose();
253254
}
254255

255256
GoTrueClient _initSupabaseAuthClient({
@@ -339,8 +340,7 @@ class SupabaseClient {
339340
event == AuthChangeEvent.tokenRefreshed ||
340341
event == AuthChangeEvent.signedIn) {
341342
realtime.setAuth(token);
342-
} else if (event == AuthChangeEvent.signedOut ||
343-
event == AuthChangeEvent.userDeleted) {
343+
} else if (event == AuthChangeEvent.signedOut) {
344344
// Token is removed
345345

346346
realtime.setAuth(_supabaseKey);

packages/supabase_flutter/lib/src/local_storage.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
88
import './local_storage_stub.dart'
99
if (dart.library.html) './local_storage_web.dart' as web;
1010

11+
/// Only used for migration from Hive to SharedPreferences. Not actually in use.
1112
const supabasePersistSessionKey = 'SUPABASE_PERSIST_SESSION_KEY';
1213

1314
/// LocalStorage is used to persist the user session in the device.

0 commit comments

Comments
 (0)