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

BREAKING: Remove SupabaseAuthRequiredState as well as overriding methods in SupabaseAuthState #124

Merged
merged 27 commits into from Jul 24, 2022
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3486a10
breaking: consolidated supabase_state, and supabase_auth_required_sta…
dshukertjr May 30, 2022
05770a4
breaking: callback methods are now optional for since they are not al…
dshukertjr May 31, 2022
fdc3ade
breaking: removed onPasswordRecovery on SupabaseAuthState. Users can …
dshukertjr May 31, 2022
ff4c4e9
breaking: remove AuthRequiredState related stuff from readme
dshukertjr Jun 1, 2022
f10cd0a
breaking: moving token refresh logic into SupabaseAuth
dshukertjr Jun 15, 2022
5a25f2f
breaking: make supabase url and anonkey required
dshukertjr Jun 15, 2022
bafed0d
breaking: removed all supabase classes and consolidate all auth relat…
dshukertjr Jun 15, 2022
837eef2
breaking: fixed tests to not use SupabaseAuthState
dshukertjr Jun 16, 2022
d5cb468
fix: lint error on Flutter 2.5 for WidgetsBinding.instance possibly null
dshukertjr Jun 16, 2022
93f630a
fix: calling ensureInitialized in tests
dshukertjr Jun 16, 2022
debb82f
fix: automatically take care of starting and ending deeplink observer
dshukertjr Jun 16, 2022
6eaae4a
fix: failing tests due to uniLink call
dshukertjr Jun 17, 2022
64d5ab9
fix: merge main
dshukertjr Jun 17, 2022
05daff9
breaking: remove unused parseUriParameters
dshukertjr Jun 18, 2022
d07f55b
fix: consolidate _initialDeeplinkIsHandled and shouldHandleInitialDee…
dshukertjr Jun 18, 2022
0d70ab3
feat: added initialSession to determine if user is signed in or not u…
dshukertjr Jun 18, 2022
7fa0572
fix: remove unnecessary if statement
dshukertjr Jun 18, 2022
da06da7
fix: make sure the initialSessionCompleter completes
dshukertjr Jun 18, 2022
4e5b56f
fix: change initialSession from Session to GoTrueSessionResponse
dshukertjr Jun 18, 2022
0a818b5
fix: initialSession completing with an error on error
dshukertjr Jun 18, 2022
689d35f
fix: typo
dshukertjr Jun 18, 2022
87d2ac5
fix: updated readme
dshukertjr Jun 18, 2022
de90849
fix: added explanation of initialSession on readme
dshukertjr Jun 18, 2022
8306877
fix: handling more errors on initialDeeplink
dshukertjr Jul 20, 2022
fd1d3ff
Merge branch 'main' into breaking/rework
dshukertjr Jul 24, 2022
2978142
Update lib/src/supabase_auth.dart
dshukertjr Jul 24, 2022
26caaa4
fix: removed analysis error
dshukertjr Jul 24, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 2 additions & 7 deletions README.md
Expand Up @@ -88,18 +88,13 @@ Future<void> signIn(String email, String password) async {

### SupabaseAuthState

It helps you handle authentication with deeplink from 3rd party service like Google, Github, Twitter...
It persists the auth state on local storage.
It also helps you handle authentication with deeplink from 3rd party service like Google, Github, Twitter...

For more details, take a look at the example [here](https://github.com/phamhieu/supabase-flutter-demo/blob/main/lib/components/auth_state.dart)

> When using with a nested authentication flow, remember to call `startAuthObserver()` and `stopAuthObserver()` before/after navigation to new screen to prevent multiple observers running at the same time. Take a look at the example [here](https://github.com/phamhieu/supabase-flutter-demo/blob/026c6e8cbb05a5b1b76a50ce82d936016844ba1b/lib/screens/signin_screen.dart#L165-L170)

### SupabaseAuthRequiredState

It helps you protect route that requires an authenticated user.

For more details, take a look at the example [here](https://github.com/phamhieu/supabase-flutter-demo/blob/main/lib/components/auth_required_state.dart)

### signInWithProvider

This method will automatically launch the auth url and open a browser for user to sign in with 3rd party login.
Expand Down
20 changes: 9 additions & 11 deletions lib/src/supabase.dart
Expand Up @@ -39,8 +39,8 @@ class Supabase {
/// This must be called only once. If called more than once, an
/// [AssertionError] is thrown
static Future<Supabase> initialize({
String? url,
String? anonKey,
required String url,
required String anonKey,
dshukertjr marked this conversation as resolved.
Show resolved Hide resolved
String? authCallbackUrlHostname,
bool? debug,
LocalStorage? localStorage,
Expand All @@ -49,16 +49,14 @@ class Supabase {
!_instance._initialized,
'This instance is already initialized',
);
if (url != null && anonKey != null) {
_instance._init(url, anonKey);
_instance._debugEnable = debug ?? kDebugMode;
_instance.log('***** Supabase init completed $_instance');
_instance._init(url, anonKey);
_instance._debugEnable = debug ?? kDebugMode;
_instance.log('***** Supabase init completed $_instance');

await SupabaseAuth.initialize(
localStorage: localStorage ?? const HiveLocalStorage(),
authCallbackUrlHostname: authCallbackUrlHostname,
);
}
await SupabaseAuth.initialize(
localStorage: localStorage ?? const HiveLocalStorage(),
authCallbackUrlHostname: authCallbackUrlHostname,
);

return _instance;
}
Expand Down
237 changes: 182 additions & 55 deletions lib/src/supabase_auth.dart
@@ -1,10 +1,16 @@
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uni_links/uni_links.dart';

import 'package:url_launcher/url_launcher.dart';

// ignore_for_file: invalid_null_aware_operator

/// SupabaseAuth
class SupabaseAuth {
class SupabaseAuth with WidgetsBindingObserver {
SupabaseAuth._();

static final SupabaseAuth _instance = SupabaseAuth._();
Expand All @@ -21,12 +27,23 @@ class SupabaseAuth {
/// {@macro supabase.localstorage.accessToken}
Future<String?> get accessToken => _localStorage.accessToken();

/// Returns when the initial session recovery is done.
/// Can be used to determine whether a user is signed in or not uplon
dshukertjr marked this conversation as resolved.
Show resolved Hide resolved
/// initial app launch.
Future<Session?> get initialSession => _initialSessionCompleter.future;
final Completer<Session?> _initialSessionCompleter = Completer();
dshukertjr marked this conversation as resolved.
Show resolved Hide resolved

/// **ATTENTION**: `getInitialLink`/`getInitialUri` should be handled
/// ONLY ONCE in your app's lifetime, since it is not meant to change
/// throughout your app's life.
bool _initialDeeplinkIsHandled = false;
String? _authCallbackUrlHostname;

GotrueSubscription? _authSubscription;
final _listenerController = StreamController<AuthChangeEvent>.broadcast();

StreamSubscription<Uri?>? _deeplinkSubscription;

/// Listen to auth change events.
///
/// ```dart
Expand Down Expand Up @@ -62,40 +79,103 @@ class SupabaseAuth {
LocalStorage localStorage = const HiveLocalStorage(),
String? authCallbackUrlHostname,
}) async {
_instance._initialized = true;
_instance._localStorage = localStorage;
_instance._authCallbackUrlHostname = authCallbackUrlHostname;

_instance._authSubscription =
Supabase.instance.client.auth.onAuthStateChange((event, session) {
_instance._onAuthStateChange(event, session);
if (!_instance._listenerController.isClosed) {
_instance._listenerController.add(event);
}
});
try {
_instance._initialized = true;
_instance._localStorage = localStorage;
_instance._authCallbackUrlHostname = authCallbackUrlHostname;

_instance._authSubscription =
Supabase.instance.client.auth.onAuthStateChange((event, session) {
_instance._onAuthStateChange(event, session);
if (!_instance._listenerController.isClosed) {
_instance._listenerController.add(event);
}
});

await _instance._localStorage.initialize();
await _instance._localStorage.initialize();

final hasPersistedSession = await _instance._localStorage.hasAccessToken();
if (hasPersistedSession) {
final persistedSession = await _instance._localStorage.accessToken();
if (persistedSession != null) {
final response = await Supabase.instance.client.auth
.recoverSession(persistedSession);
final hasPersistedSession =
await _instance._localStorage.hasAccessToken();
if (hasPersistedSession) {
final persistedSession = await _instance._localStorage.accessToken();
if (persistedSession != null) {
final response = await Supabase.instance.client.auth
.recoverSession(persistedSession);
final error = response.error;

if (response.error != null) {
Supabase.instance.log(response.error!.message);
if (error != null) {
Supabase.instance.log(response.error!.message);
if (!_instance._initialSessionCompleter.isCompleted) {
_instance._initialSessionCompleter.completeError(error);
}
}
if (!_instance._initialSessionCompleter.isCompleted) {
_instance._initialSessionCompleter.complete(response.data);
}
}
}
}
WidgetsBinding.instance?.addObserver(_instance);
_instance._startDeeplinkObserver();

return _instance;
if (!_instance._initialSessionCompleter.isCompleted) {
// Complete with null if the user did not have persisted session
_instance._initialSessionCompleter.complete(null);
}
return _instance;
} catch (e) {
if (!_instance._initialSessionCompleter.isCompleted) {
_instance._initialSessionCompleter.completeError(e);
}
rethrow;
dshukertjr marked this conversation as resolved.
Show resolved Hide resolved
}
}

/// Dispose the instance to free up resources
void dispose() {
_listenerController.close();
_authSubscription?.data?.unsubscribe();
_stopDeeplinkObserver();
WidgetsBinding.instance?.removeObserver(this);
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
_recoverSupabaseSession();
break;
case AppLifecycleState.inactive:
break;
case AppLifecycleState.paused:
break;
case AppLifecycleState.detached:
break;
}
}

/// Recover/refresh session if it's available
/// e.g. called on a Splash screen when app starts.
Future<bool> _recoverSupabaseSession() async {
final bool exist =
await SupabaseAuth.instance.localStorage.hasAccessToken();
if (!exist) {
return false;
}

final String? jsonStr =
await SupabaseAuth.instance.localStorage.accessToken();
if (jsonStr == null) {
return false;
}

final response =
await Supabase.instance.client.auth.recoverSession(jsonStr);
dshukertjr marked this conversation as resolved.
Show resolved Hide resolved
if (response.error != null) {
SupabaseAuth.instance.localStorage.removePersistedSession();
return false;
} else {
return true;
}
}

void _onAuthStateChange(AuthChangeEvent event, Session? session) {
Expand All @@ -108,49 +188,96 @@ class SupabaseAuth {
}
}

/// Parse Uri parameters from redirect url/deeplink
Map<String, String> parseUriParameters(Uri uri) {
Uri _uri = uri;
if (_uri.hasQuery) {
final decoded = _uri.toString().replaceAll('#', '&');
_uri = Uri.parse(decoded);
/// if _authCallbackUrlHost not init, we treat all deeplink as auth callback
bool _isAuthCallbackDeeplink(Uri uri) {
if (_authCallbackUrlHostname == null) {
return true;
} else {
final uriStr = _uri.toString();
String decoded;
// %23 is the encoded of #hash
// support custom redirect to on flutter web
if (uriStr.contains('/#%23')) {
decoded = uriStr.replaceAll('/#%23', '/?');
} else if (uriStr.contains('/#/')) {
decoded = uriStr.replaceAll('/#/', '/').replaceAll('%23', '?');
} else {
decoded = uriStr.replaceAll('#', '?');
}
_uri = Uri.parse(decoded);
return _authCallbackUrlHostname == uri.host;
}
}

/// Enable deep link observer to handle deep links
void _startDeeplinkObserver() {
Supabase.instance.log('***** SupabaseDeepLinkingMixin startAuthObserver');
_handleIncomingLinks();
_handleInitialUri();
}

/// Stop deep link observer
///
/// Automatically called on dispose().
void _stopDeeplinkObserver() {
Supabase.instance.log('***** SupabaseDeepLinkingMixin stopAuthObserver');
_deeplinkSubscription?.cancel();
}

/// Handle incoming links - the ones that the app will recieve from the OS
/// while already started.
void _handleIncomingLinks() {
if (!kIsWeb) {
// It will handle app links while the app is already started - be it in
// the foreground or in the background.
_deeplinkSubscription = uriLinkStream.listen(
(Uri? uri) {
if (uri != null) {
_handleDeeplink(uri);
}
},
onError: (Object err) {
_onErrorReceivingDeeplink(err.toString());
},
);
}
return _uri.queryParameters;
}

/// Handle the initial Uri - the one the app was started with
///
/// **ATTENTION**: `getInitialLink`/`getInitialUri` should be handled
/// ONLY ONCE in your app's lifetime, since it is not meant to change
/// throughout your app's life.
bool shouldHandleInitialDeeplink() {
if (_initialDeeplinkIsHandled) {
return false;
} else {
_initialDeeplinkIsHandled = true;
return true;
///
/// We handle all exceptions, since it is called from initState.
Future<void> _handleInitialUri() async {
if (_initialDeeplinkIsHandled) return;
_initialDeeplinkIsHandled = true;

try {
final uri = await getInitialUri();
if (uri != null) {
_handleDeeplink(uri);
}
} on PlatformException {
// Platform messages may fail but we ignore the exception
bdlukaa marked this conversation as resolved.
Show resolved Hide resolved
} on FormatException catch (err) {
_onErrorReceivingDeeplink(err.message);
}
dshukertjr marked this conversation as resolved.
Show resolved Hide resolved
}

/// if _authCallbackUrlHost not init, we treat all deeplink as auth callback
bool isAuthCallbackDeeplink(Uri uri) {
if (_authCallbackUrlHostname == null) {
return true;
} else {
return _authCallbackUrlHostname == uri.host;
/// Callback when deeplink receiving succeeds
Future<void> _handleDeeplink(Uri uri) async {
if (!_instance._isAuthCallbackDeeplink(uri)) return;

Supabase.instance.log('***** SupabaseAuthState handleDeeplink $uri');

// notify auth deeplink received
Supabase.instance.log('onReceivedAuthDeeplink uri: $uri');

await _recoverSessionFromUrl(uri);
}

Future<void> _recoverSessionFromUrl(Uri uri) async {
// recover session from deeplink
final response = await Supabase.instance.client.auth.getSessionFromUrl(uri);
if (response.error != null) {
Supabase.instance.log(response.error!.message);
}
}

/// Callback when deeplink receiving throw error
void _onErrorReceivingDeeplink(String message) {
Supabase.instance.log('onErrorReceivingDeppLink message: $message');
}
}

extension GoTrueClientSignInProvider on GoTrueClient {
Expand Down