Skip to content

Commit

Permalink
feat: Broadcast auth events to other tabs on web (#1005)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Vinzent03 authored Sep 20, 2024
1 parent 773b7de commit 8f473f1
Show file tree
Hide file tree
Showing 10 changed files with 150 additions and 19 deletions.
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() {
_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

0 comments on commit 8f473f1

Please sign in to comment.