diff --git a/packages/supabase_flutter/README.md b/packages/supabase_flutter/README.md index cc948dabd..5bd8de402 100644 --- a/packages/supabase_flutter/README.md +++ b/packages/supabase_flutter/README.md @@ -186,9 +186,9 @@ Future _googleSignIn() async { ### OAuth login -The `signInWithIdToken()` method supports providers like Apple, Google, Facebook, Kakao, and Keycloak. For other providers, you need to use the `signInWithOAuth()` method to perform OAuth login. This will open the web browser to perform the OAuth login. +The `signInWithIdToken()` method supports providers like Apple, Google, Facebook, Kakao, and Keycloak. For other providers, you need to use the `signInWithOAuth()` method to perform OAuth login. On native platforms this opens a system web authentication session (`ASWebAuthenticationSession` on iOS and macOS, Custom Tabs on Android) that closes itself once the login completes. On web the current tab is redirected to the provider. -Use the `redirectTo` parameter to redirect the user to a deep link to bring the user back to the app. Learn more about setting up deep links in [Deep link config](#deep-link-config). +Use the `redirectTo` parameter to send the user back to the app after login. Its scheme is also the callback scheme the web authentication session listens for, so on native platforms you need to register it. See [OAuth native config](#oauth-native-config). ```dart // Perform web based OAuth login @@ -206,6 +206,37 @@ supabase.auth.onAuthStateChange.listen((data) { }); ``` +#### OAuth native config + +On native platforms `signInWithOAuth()`, `signInWithSSO()` and `linkIdentity()` run inside a system web authentication session that captures the redirect back to your app. The session listens for the scheme of your `redirectTo` URL, so that scheme has to be registered with the platform. + +**Android** + +Register the callback activity in `android/app/src/main/AndroidManifest.xml`, inside the `` tag, using the scheme of your `redirectTo` (here `io.supabase.flutter`): + +```xml + + + + + + + + +``` + +**iOS and macOS** + +No configuration is required for custom URL schemes. If you use an `https` `redirectTo` (universal link), it is passed through automatically; on iOS 17.4+ and macOS 14.4+ the host and path are taken from `redirectTo` to satisfy the universal link requirements. + +**Web** + +No configuration is required. The current tab is redirected to the provider and the session is restored when the browser returns to your app. + +If you only need OAuth, SSO and identity linking, this replaces the app links deep link setup below. You still need to set up deep links for magic links, email confirmation and password recovery, which arrive as real deep links from outside the app. + ### [Database](https://supabase.com/docs/guides/database) Database methods are used to perform basic CRUD operations using the Supabase REST API. Full list of supported operators can be found [here](https://supabase.com/docs/reference/dart/select). @@ -365,7 +396,8 @@ You need to setup deep links if you want your native app to open when a user cli - Magic link login - Have `confirm email` enabled and are using email login - Resetting password for email login -- Calling `.signInWithOAuth()` method + +`.signInWithOAuth()`, `.signInWithSSO()` and `.linkIdentity()` no longer rely on these deep links. They run inside a system web authentication session that captures the redirect itself, see [OAuth native config](#oauth-native-config). \*Currently supabase_flutter supports deep links on Android, iOS, Web, MacOS and Windows. diff --git a/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc b/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc index 3792af4b6..c265fcdf8 100644 --- a/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc +++ b/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc @@ -6,14 +6,22 @@ #include "generated_plugin_registrant.h" +#include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin"); + desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_to_front_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowToFrontPlugin"); + window_to_front_plugin_register_with_registrar(window_to_front_registrar); } diff --git a/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake b/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake index 21d8f8bbb..eda9ca919 100644 --- a/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake +++ b/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake @@ -3,8 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + desktop_webview_window gtk url_launcher_linux + window_to_front ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift index a620c94b7..b9c63ab71 100644 --- a/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,17 @@ import FlutterMacOS import Foundation import app_links +import desktop_webview_window +import flutter_web_auth_2 import shared_preferences_foundation import url_launcher_macos +import window_to_front func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin")) + FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin")) } diff --git a/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc b/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc index 785a046f9..e661cc0ed 100644 --- a/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,17 @@ #include "generated_plugin_registrant.h" #include +#include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + DesktopWebviewWindowPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowToFrontPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowToFrontPlugin")); } diff --git a/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake b/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake index 642b1bed3..3ca96d565 100644 --- a/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake +++ b/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake @@ -4,7 +4,9 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + desktop_webview_window url_launcher_windows + window_to_front ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/packages/supabase_flutter/lib/src/oauth_redirect_stub.dart b/packages/supabase_flutter/lib/src/oauth_redirect_stub.dart new file mode 100644 index 000000000..d8945efc3 --- /dev/null +++ b/packages/supabase_flutter/lib/src/oauth_redirect_stub.dart @@ -0,0 +1,7 @@ +// coverage:ignore-file + +/// Navigates the current browser tab to [url]. +/// +/// Only meaningful on web. The stub throws because native and desktop platforms +/// go through the web auth session instead of a full-page redirect. +void redirectToUrl(String url) => throw UnimplementedError(); diff --git a/packages/supabase_flutter/lib/src/oauth_redirect_web.dart b/packages/supabase_flutter/lib/src/oauth_redirect_web.dart new file mode 100644 index 000000000..a6621d26f --- /dev/null +++ b/packages/supabase_flutter/lib/src/oauth_redirect_web.dart @@ -0,0 +1,5 @@ +import 'package:web/web.dart'; + +/// Navigates the current browser tab to [url], preserving the full-page +/// redirect behavior the OAuth flow relies on for web. +void redirectToUrl(String url) => window.location.assign(url); diff --git a/packages/supabase_flutter/lib/src/supabase_auth.dart b/packages/supabase_flutter/lib/src/supabase_auth.dart index 4560e2d81..b4237fe2c 100644 --- a/packages/supabase_flutter/lib/src/supabase_auth.dart +++ b/packages/supabase_flutter/lib/src/supabase_auth.dart @@ -7,9 +7,12 @@ import 'package:app_links/app_links.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:logging/logging.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:url_launcher/url_launcher.dart'; + +import './oauth_redirect_stub.dart' + if (dart.library.js_interop) './oauth_redirect_web.dart'; /// Integrates Supabase Auth with the Flutter application lifecycle. /// @@ -299,8 +302,8 @@ extension GoTrueClientSignInProvider on GoTrueClient { OAuthProvider provider, { String? redirectTo, String? scopes, - LaunchMode authScreenLaunchMode = LaunchMode.platformDefault, Map? queryParams, + bool preferEphemeral = false, }) async { final res = await getOAuthSignInUrl( provider: provider, @@ -308,24 +311,11 @@ extension GoTrueClientSignInProvider on GoTrueClient { scopes: scopes, queryParams: queryParams, ); - final uri = Uri.parse(res.url); - - LaunchMode launchMode = authScreenLaunchMode; - - // `Platform.isAndroid` throws on web, so adding a guard for web here. - final isAndroid = !kIsWeb && Platform.isAndroid; - - // Google login has to be performed on external browser window on Android - if (provider == OAuthProvider.google && isAndroid) { - launchMode = LaunchMode.externalApplication; - } - - final result = await launchUrl( - uri, - mode: launchMode, - webOnlyWindowName: '_self', + return _authenticateWithRedirect( + Uri.parse(res.url), + redirectTo: redirectTo, + preferEphemeral: preferEphemeral, ); - return result; } /// Attempts a single-sign on using an enterprise Identity Provider. A @@ -341,8 +331,9 @@ extension GoTrueClientSignInProvider on GoTrueClient { /// If you have built an organization-specific login page, you can use the /// organization's SSO Identity Provider UUID directly instead. /// - /// Returns true if the URL was launched successfully, otherwise either returns - /// false or throws a [PlatformException] depending on the launchUrl failure. + /// On web the current tab is redirected to the identity provider. On every + /// other platform the flow runs inside a system web authentication session + /// and resolves once the session has been established. /// /// ```dart /// await supabase.auth.signInWithSSO( @@ -354,7 +345,7 @@ extension GoTrueClientSignInProvider on GoTrueClient { String? domain, String? redirectTo, String? captchaToken, - LaunchMode launchMode = LaunchMode.platformDefault, + bool preferEphemeral = false, }) async { final ssoUrl = await getSSOSignInUrl( providerId: providerId, @@ -362,10 +353,10 @@ extension GoTrueClientSignInProvider on GoTrueClient { redirectTo: redirectTo, captchaToken: captchaToken, ); - return await launchUrl( + return _authenticateWithRedirect( Uri.parse(ssoUrl), - mode: launchMode, - webOnlyWindowName: '_self', + redirectTo: redirectTo, + preferEphemeral: preferEphemeral, ); } @@ -380,8 +371,8 @@ extension GoTrueClientSignInProvider on GoTrueClient { OAuthProvider provider, { String? redirectTo, String? scopes, - LaunchMode authScreenLaunchMode = LaunchMode.platformDefault, Map? queryParams, + bool preferEphemeral = false, }) async { final res = await getLinkIdentityUrl( provider, @@ -389,23 +380,52 @@ extension GoTrueClientSignInProvider on GoTrueClient { scopes: scopes, queryParams: queryParams, ); - final uri = Uri.parse(res.url); - - LaunchMode launchMode = authScreenLaunchMode; + return _authenticateWithRedirect( + Uri.parse(res.url), + redirectTo: redirectTo, + preferEphemeral: preferEphemeral, + ); + } - // `Platform.isAndroid` throws on web, so adding a guard for web here. - final isAndroid = !kIsWeb && Platform.isAndroid; + /// Runs an OAuth-style redirect flow for [url]. + /// + /// On web the current tab is redirected to [url] and the session is picked up + /// when the browser returns to the app. On every other platform the flow runs + /// inside a system web authentication session (`ASWebAuthenticationSession` on + /// Apple platforms, Custom Tabs on Android) which captures the redirect to + /// [redirectTo] and hands it back, so the session is established before this + /// returns. [preferEphemeral] requests an ephemeral session that does not + /// share cookies with the system browser (Apple platforms and Android only). + Future _authenticateWithRedirect( + Uri url, { + required String? redirectTo, + required bool preferEphemeral, + }) async { + if (kIsWeb) { + redirectToUrl(url.toString()); + return true; + } - // Google login has to be performed on external browser window on Android - if (provider == OAuthProvider.google && isAndroid) { - launchMode = LaunchMode.externalApplication; + if (redirectTo == null) { + throw const AuthException( + 'redirectTo is required to capture the authentication callback on this ' + 'platform.', + ); } - final result = await launchUrl( - uri, - mode: launchMode, - webOnlyWindowName: '_self', + final redirectUri = Uri.parse(redirectTo); + final isHttps = redirectUri.scheme == 'https'; + final result = await FlutterWebAuth2.authenticate( + url: url.toString(), + callbackUrlScheme: redirectUri.scheme, + options: FlutterWebAuth2Options( + preferEphemeral: preferEphemeral, + httpsHost: isHttps ? redirectUri.host : null, + httpsPath: isHttps ? redirectUri.path : null, + ), ); - return result; + + await getSessionFromUrl(Uri.parse(result)); + return true; } } diff --git a/packages/supabase_flutter/lib/supabase_flutter.dart b/packages/supabase_flutter/lib/supabase_flutter.dart index 9234a9154..ce83139fe 100644 --- a/packages/supabase_flutter/lib/supabase_flutter.dart +++ b/packages/supabase_flutter/lib/supabase_flutter.dart @@ -4,7 +4,6 @@ library supabase_flutter; export 'package:supabase/supabase.dart'; -export 'package:url_launcher/url_launcher.dart' show LaunchMode; export 'src/flutter_go_true_client_options.dart'; export 'src/local_storage.dart'; diff --git a/packages/supabase_flutter/pubspec.yaml b/packages/supabase_flutter/pubspec.yaml index d82052930..587bed8f3 100644 --- a/packages/supabase_flutter/pubspec.yaml +++ b/packages/supabase_flutter/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: http: '>=0.13.4 <2.0.0' meta: ^1.7.0 supabase: 2.11.0 - url_launcher: ^6.1.2 + flutter_web_auth_2: ^5.0.0 path_provider: ^2.0.0 shared_preferences: ^2.0.0 logging: ^1.2.0 @@ -29,7 +29,9 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.1 + flutter_web_auth_2_platform_interface: ^5.0.0 path: ^1.8.3 + plugin_platform_interface: ^2.0.0 web_socket_channel: '>=2.3.0 <4.0.0' platforms: diff --git a/packages/supabase_flutter/test/oauth_test.dart b/packages/supabase_flutter/test/oauth_test.dart new file mode 100644 index 000000000..b70943873 --- /dev/null +++ b/packages/supabase_flutter/test/oauth_test.dart @@ -0,0 +1,91 @@ +@TestOn('!browser') + +/// Tests for the native OAuth flow that runs through the system web +/// authentication session. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'widget_test_stubs.dart'; + +void main() { + late FakeFlutterWebAuth2 fakeWebAuth; + late PkceHttpClient pkceHttpClient; + + setUp(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + fakeWebAuth = FakeFlutterWebAuth2( + 'io.supabase.flutter://callback/?code=my-code-verifier', + ); + FlutterWebAuth2Platform.instance = fakeWebAuth; + + pkceHttpClient = PkceHttpClient(); + + await Supabase.initialize( + url: 'https://test.supabase.co', + publishableKey: '', + debug: false, + httpClient: pkceHttpClient, + authOptions: FlutterAuthClientOptions( + localStorage: MockEmptyLocalStorage(), + pkceAsyncStorage: MockAsyncStorage(), + ), + ); + }); + + tearDown(() async { + await Supabase.instance.dispose(); + }); + + test( + 'signInWithOAuth runs the web auth session and exchanges the returned code', + () async { + await Supabase.instance.client.auth.signInWithOAuth( + OAuthProvider.github, + redirectTo: 'io.supabase.flutter://callback', + ); + + // The authorize URL is opened in the web auth session, and the callback + // scheme is derived from redirectTo. + expect(fakeWebAuth.authenticatedUrl, contains('/auth/v1/authorize')); + expect(fakeWebAuth.authenticatedUrl, contains('provider=github')); + expect(fakeWebAuth.callbackUrlScheme, 'io.supabase.flutter'); + + // The code from the callback URL is exchanged for a session. + expect(pkceHttpClient.lastRequestBody['auth_code'], 'my-code-verifier'); + expect(Supabase.instance.client.auth.currentUser?.email, 'fake1@email.com'); + }); + + test('preferEphemeral is forwarded to the web auth session options', + () async { + await Supabase.instance.client.auth.signInWithOAuth( + OAuthProvider.github, + redirectTo: 'io.supabase.flutter://callback', + preferEphemeral: true, + ); + + expect(fakeWebAuth.options?['preferEphemeral'], true); + }); + + test('https redirectTo forwards host and path for universal links', () async { + await Supabase.instance.client.auth.signInWithOAuth( + OAuthProvider.github, + redirectTo: 'https://myapp.com/auth/callback', + ); + + expect(fakeWebAuth.callbackUrlScheme, 'https'); + expect(fakeWebAuth.options?['httpsHost'], 'myapp.com'); + expect(fakeWebAuth.options?['httpsPath'], '/auth/callback'); + }); + + test('signInWithOAuth without redirectTo throws on native platforms', + () async { + await expectLater( + Supabase.instance.client.auth.signInWithOAuth(OAuthProvider.github), + throwsA(isA()), + ); + }); +} diff --git a/packages/supabase_flutter/test/widget_test_stubs.dart b/packages/supabase_flutter/test/widget_test_stubs.dart index fc3c4789e..98785c63b 100644 --- a/packages/supabase_flutter/test/widget_test_stubs.dart +++ b/packages/supabase_flutter/test/widget_test_stubs.dart @@ -4,11 +4,43 @@ import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; import 'package:http/http.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'utils.dart'; +/// Fake [FlutterWebAuth2Platform] that records the authentication request and +/// returns a preconfigured callback URL, standing in for the system web auth +/// session in tests. +class FakeFlutterWebAuth2 extends FlutterWebAuth2Platform + with MockPlatformInterfaceMixin { + FakeFlutterWebAuth2(this.callbackUrl); + + /// The callback URL the fake session resolves with. + final String callbackUrl; + + String? authenticatedUrl; + String? callbackUrlScheme; + Map? options; + + @override + Future authenticate({ + required String url, + required String callbackUrlScheme, + required Map options, + }) async { + authenticatedUrl = url; + this.callbackUrlScheme = callbackUrlScheme; + this.options = options; + return callbackUrl; + } + + @override + Future clearAllDanglingCalls() async {} +} + class MockWidget extends StatefulWidget { const MockWidget({super.key});