Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Broadcast auth events to other tabs on web #1005

Merged
merged 11 commits into from
Sep 20, 2024
3 changes: 1 addition & 2 deletions packages/gotrue/lib/gotrue.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 6 additions & 0 deletions packages/gotrue/lib/src/broadcast_stub.dart
Original file line number Diff line number Diff line change
@@ -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();
}
23 changes: 23 additions & 0 deletions packages/gotrue/lib/src/broadcast_web.dart
Original file line number Diff line number Diff line change
@@ -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,
);
}
21 changes: 13 additions & 8 deletions packages/gotrue/lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
85 changes: 83 additions & 2 deletions packages/gotrue/lib/src/gotrue_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -116,6 +124,8 @@ class GoTrueClient {
if (_autoRefreshToken) {
startAutoRefresh();
}

_mayStartBroadcastChannel();
}

/// Getter for the headers
Expand Down Expand Up @@ -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() {
Vinzent03 marked this conversation as resolved.
Show resolved Hide resolved
_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].
Expand Down Expand Up @@ -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);
}
Expand Down
11 changes: 10 additions & 1 deletion packages/gotrue/lib/src/types/auth_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}';
}
}
4 changes: 0 additions & 4 deletions packages/gotrue/lib/src/types/oauth_flow_type.dart

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
typedef BroadcastChannel = ({
Stream<Map<String, dynamic>> onMessage,
void Function(Map) postMessage,
void Function() close,
});

enum AuthFlowType {
implicit,
pkce,
}

enum OAuthProvider {
apple,
azure,
Expand Down
4 changes: 2 additions & 2 deletions packages/supabase/lib/src/supabase_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ class SupabaseClient {
Future<void> dispose() async {
await _authStateSubscription?.cancel();
await _isolate.dispose();
auth.dispose();
}

GoTrueClient _initSupabaseAuthClient({
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/supabase_flutter/lib/src/local_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down