From 8f473f1a99e0cbb9d570eb3fff0786ed5084351c Mon Sep 17 00:00:00 2001 From: Vinzent Date: Fri, 20 Sep 2024 12:53:36 +0200 Subject: [PATCH] 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 --- packages/gotrue/lib/gotrue.dart | 3 +- packages/gotrue/lib/src/broadcast_stub.dart | 6 ++ packages/gotrue/lib/src/broadcast_web.dart | 23 +++++ packages/gotrue/lib/src/constants.dart | 21 +++-- packages/gotrue/lib/src/gotrue_client.dart | 85 ++++++++++++++++++- packages/gotrue/lib/src/types/auth_state.dart | 11 ++- .../gotrue/lib/src/types/oauth_flow_type.dart | 4 - .../{o_auth_provider.dart => types.dart} | 11 +++ .../supabase/lib/src/supabase_client.dart | 4 +- .../lib/src/local_storage.dart | 1 + 10 files changed, 150 insertions(+), 19 deletions(-) create mode 100644 packages/gotrue/lib/src/broadcast_stub.dart create mode 100644 packages/gotrue/lib/src/broadcast_web.dart delete mode 100644 packages/gotrue/lib/src/types/oauth_flow_type.dart rename packages/gotrue/lib/src/types/{o_auth_provider.dart => types.dart} (56%) diff --git a/packages/gotrue/lib/gotrue.dart b/packages/gotrue/lib/gotrue.dart index 7e27c223..0799ee52 100644 --- a/packages/gotrue/lib/gotrue.dart +++ b/packages/gotrue/lib/gotrue.dart @@ -9,8 +9,7 @@ export 'src/types/auth_response.dart' hide ToSnakeCase; export 'src/types/auth_state.dart'; export 'src/types/gotrue_async_storage.dart'; export 'src/types/mfa.dart'; -export 'src/types/o_auth_provider.dart'; -export 'src/types/oauth_flow_type.dart'; +export 'src/types/types.dart'; export 'src/types/session.dart'; export 'src/types/user.dart'; export 'src/types/user_attributes.dart'; diff --git a/packages/gotrue/lib/src/broadcast_stub.dart b/packages/gotrue/lib/src/broadcast_stub.dart new file mode 100644 index 00000000..95ee5d0e --- /dev/null +++ b/packages/gotrue/lib/src/broadcast_stub.dart @@ -0,0 +1,6 @@ +import 'package:gotrue/src/types/types.dart'; + +/// Stub implementation of [BroadcastChannel] for platforms that don't support it. +BroadcastChannel getBroadcastChannel(String broadcastKey) { + throw UnimplementedError(); +} diff --git a/packages/gotrue/lib/src/broadcast_web.dart b/packages/gotrue/lib/src/broadcast_web.dart new file mode 100644 index 00000000..b754666a --- /dev/null +++ b/packages/gotrue/lib/src/broadcast_web.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; +import 'dart:html' as html; +import 'dart:js_util' as js_util; + +import 'package:gotrue/src/types/types.dart'; + +BroadcastChannel getBroadcastChannel(String broadcastKey) { + final broadcast = html.BroadcastChannel(broadcastKey); + return ( + onMessage: broadcast.onMessage.map((event) { + final dataMap = js_util.dartify(event.data); + + // some parts have the wrong map type. This is an easy workaround and + // should be efficient enough for the small session and user data + return json.decode(json.encode(dataMap)); + }), + postMessage: (message) { + final jsMessage = js_util.jsify(message); + broadcast.postMessage(jsMessage); + }, + close: broadcast.close, + ); +} diff --git a/packages/gotrue/lib/src/constants.dart b/packages/gotrue/lib/src/constants.dart index e1bdc87d..c44d5ff4 100644 --- a/packages/gotrue/lib/src/constants.dart +++ b/packages/gotrue/lib/src/constants.dart @@ -34,14 +34,19 @@ class ApiVersions { } enum AuthChangeEvent { - initialSession, - passwordRecovery, - signedIn, - signedOut, - tokenRefreshed, - userUpdated, - userDeleted, - mfaChallengeVerified, + initialSession('INITIAL_SESSION'), + passwordRecovery('PASSWORD_RECOVERY'), + signedIn('SIGNED_IN'), + signedOut('SIGNED_OUT'), + tokenRefreshed('TOKEN_REFRESHED'), + userUpdated('USER_UPDATED'), + + @Deprecated('Was never in use and might be removed in the future.') + userDeleted(''), + mfaChallengeVerified('MFA_CHALLENGE_VERIFIED'); + + final String jsName; + const AuthChangeEvent(this.jsName); } extension AuthChangeEventExtended on AuthChangeEvent { diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 08c4e90e..8a2c0e2b 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -15,6 +15,9 @@ import 'package:meta/meta.dart'; import 'package:retry/retry.dart'; import 'package:rxdart/subjects.dart'; +import 'broadcast_stub.dart' if (dart.library.html) './broadcast_web.dart' + as web; + part 'gotrue_mfa_api.dart'; /// {@template gotrue_client} @@ -84,6 +87,11 @@ class GoTrueClient { final AuthFlowType _flowType; + /// Proxy to the web BroadcastChannel API. Should be null on non-web platforms. + BroadcastChannel? _broadcastChannel; + + StreamSubscription? _broadcastChannelSubscription; + /// {@macro gotrue_client} GoTrueClient({ String? url, @@ -116,6 +124,8 @@ class GoTrueClient { if (_autoRefreshToken) { startAutoRefresh(); } + + _mayStartBroadcastChannel(); } /// Getter for the headers @@ -1128,6 +1138,63 @@ class GoTrueClient { _currentUser = null; } + void _mayStartBroadcastChannel() { + if (const bool.fromEnvironment('dart.library.html')) { + // Used by the js library as well + final broadcastKey = + "sb-${Uri.parse(_url).host.split(".").first}-auth-token"; + + assert(_broadcastChannel == null, + 'Broadcast channel should not be started more than once.'); + try { + _broadcastChannel = web.getBroadcastChannel(broadcastKey); + _broadcastChannelSubscription = + _broadcastChannel?.onMessage.listen((messageEvent) { + final rawEvent = messageEvent['event']; + final event = switch (rawEvent) { + // This library sends the js name of the event to be comptabile with + // the js library, so we need to convert it back to the dart name + 'INITIAL_SESSION' => AuthChangeEvent.initialSession, + 'PASSWORD_RECOVERY' => AuthChangeEvent.passwordRecovery, + 'SIGNED_IN' => AuthChangeEvent.signedIn, + 'SIGNED_OUT' => AuthChangeEvent.signedOut, + 'TOKEN_REFRESHED' => AuthChangeEvent.tokenRefreshed, + 'USER_UPDATED' => AuthChangeEvent.userUpdated, + 'MFA_CHALLENGE_VERIFIED' => AuthChangeEvent.mfaChallengeVerified, + // This case should never happen though + _ => AuthChangeEvent.values + .firstWhereOrNull((event) => event.name == rawEvent), + }; + + if (event != null) { + Session? session; + if (messageEvent['session'] != null) { + session = Session.fromJson(messageEvent['session']); + } + if (session != null) { + _saveSession(session); + } else { + _removeSession(); + } + notifyAllSubscribers(event, session: session, broadcast: false); + } + }); + } catch (e) { + // Ignoring + } + } + } + + @mustCallSuper + void dispose() { + _onAuthStateChangeController.close(); + _onAuthStateChangeControllerSync.close(); + _broadcastChannel?.close(); + _broadcastChannelSubscription?.cancel(); + _refreshTokenCompleter?.completeError(AuthException('Disposed')); + _autoRefreshTicker?.cancel(); + } + /// Generates a new JWT. /// /// To prevent multiple simultaneous requests it catches an already ongoing request by using the global [_refreshTokenCompleter]. @@ -1181,9 +1248,23 @@ class GoTrueClient { } /// For internal use only. + /// + /// [broadcast] is used to determine if the event should be broadcasted to + /// other tabs. @internal - void notifyAllSubscribers(AuthChangeEvent event) { - final state = AuthState(event, currentSession); + void notifyAllSubscribers( + AuthChangeEvent event, { + Session? session, + bool broadcast = true, + }) { + session ??= currentSession; + if (broadcast && event != AuthChangeEvent.initialSession) { + _broadcastChannel?.postMessage({ + 'event': event.jsName, + 'session': session?.toJson(), + }); + } + final state = AuthState(event, session, fromBroadcast: !broadcast); _onAuthStateChangeController.add(state); _onAuthStateChangeControllerSync.add(state); } diff --git a/packages/gotrue/lib/src/types/auth_state.dart b/packages/gotrue/lib/src/types/auth_state.dart index b23b612a..c610790a 100644 --- a/packages/gotrue/lib/src/types/auth_state.dart +++ b/packages/gotrue/lib/src/types/auth_state.dart @@ -5,5 +5,14 @@ class AuthState { final AuthChangeEvent event; final Session? session; - AuthState(this.event, this.session); + /// Whether this state was broadcasted via `html.ChannelBroadcast` on web from + /// another tab or window. + final bool fromBroadcast; + + const AuthState(this.event, this.session, {this.fromBroadcast = false}); + + @override + String toString() { + return 'AuthState{event: $event, session: $session, fromBroadcast: $fromBroadcast}'; + } } diff --git a/packages/gotrue/lib/src/types/oauth_flow_type.dart b/packages/gotrue/lib/src/types/oauth_flow_type.dart deleted file mode 100644 index 51803805..00000000 --- a/packages/gotrue/lib/src/types/oauth_flow_type.dart +++ /dev/null @@ -1,4 +0,0 @@ -enum AuthFlowType { - implicit, - pkce, -} diff --git a/packages/gotrue/lib/src/types/o_auth_provider.dart b/packages/gotrue/lib/src/types/types.dart similarity index 56% rename from packages/gotrue/lib/src/types/o_auth_provider.dart rename to packages/gotrue/lib/src/types/types.dart index 1851bc7a..a2d11a69 100644 --- a/packages/gotrue/lib/src/types/o_auth_provider.dart +++ b/packages/gotrue/lib/src/types/types.dart @@ -1,3 +1,14 @@ +typedef BroadcastChannel = ({ + Stream> onMessage, + void Function(Map) postMessage, + void Function() close, +}); + +enum AuthFlowType { + implicit, + pkce, +} + enum OAuthProvider { apple, azure, diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index 3c5a9c63..b51b4ca8 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -250,6 +250,7 @@ class SupabaseClient { Future dispose() async { await _authStateSubscription?.cancel(); await _isolate.dispose(); + auth.dispose(); } GoTrueClient _initSupabaseAuthClient({ @@ -339,8 +340,7 @@ class SupabaseClient { event == AuthChangeEvent.tokenRefreshed || event == AuthChangeEvent.signedIn) { realtime.setAuth(token); - } else if (event == AuthChangeEvent.signedOut || - event == AuthChangeEvent.userDeleted) { + } else if (event == AuthChangeEvent.signedOut) { // Token is removed realtime.setAuth(_supabaseKey); diff --git a/packages/supabase_flutter/lib/src/local_storage.dart b/packages/supabase_flutter/lib/src/local_storage.dart index 607d4a15..7b201584 100644 --- a/packages/supabase_flutter/lib/src/local_storage.dart +++ b/packages/supabase_flutter/lib/src/local_storage.dart @@ -8,6 +8,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; import './local_storage_stub.dart' if (dart.library.html) './local_storage_web.dart' as web; +/// Only used for migration from Hive to SharedPreferences. Not actually in use. const supabasePersistSessionKey = 'SUPABASE_PERSIST_SESSION_KEY'; /// LocalStorage is used to persist the user session in the device.