From efae3bfedfe28231eaab780dbbd68a618f55aaea Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 16 Oct 2026 15:03:06 +0200 Subject: [PATCH] fix(auth): check persistSession up front and give examples a storage --- MIGRATION.md | 4 +-- .../integration_test/room_test.dart | 6 +++- packages/supabase/example/web/main.dart | 6 +++- .../supabase/lib/src/supabase_client.dart | 35 ++++++++++--------- packages/supabase/test/client_test.dart | 20 +++++++++++ packages/supabase_auth/test/client_test.dart | 20 +++++++++++ 6 files changed, 71 insertions(+), 20 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 8a25de961..1d1ae2c31 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -538,8 +538,8 @@ the upgrade still completes after it. The pkce flow, which is the default, keeps its code verifiers in the `asyncStorage` of `AuthClientOptions`. A `SupabaseClient` or `AuthClient` created without one used to fail an assert, so `dart run` and AOT builds only failed at the first pkce call. Both now throw an `ArgumentError` -when they are created, in debug and release builds alike. So does an `AuthClient` that persists the -session without an `asyncStorage`. +when they are created, in debug and release builds alike. So does either client when it persists +the session without an `asyncStorage`. `Supabase.initialize` configures a storage for you. A standalone script or a server builds its own client and has to pass one, or use the implicit flow: diff --git a/examples/realtime_room/integration_test/room_test.dart b/examples/realtime_room/integration_test/room_test.dart index 268c82f9e..3d5c62640 100644 --- a/examples/realtime_room/integration_test/room_test.dart +++ b/examples/realtime_room/integration_test/room_test.dart @@ -66,7 +66,11 @@ void main() { await _pumpUntil(tester, find.text('hello from alice')); // A second client stands in for another person in the room. - final bobClient = SupabaseClient(supabaseUrl, supabasePublishableKey); + final bobClient = SupabaseClient( + supabaseUrl, + supabasePublishableKey, + authOptions: AuthClientOptions(asyncStorage: MemoryAuthAsyncStorage()), + ); addTearDown(bobClient.dispose); final bobRepository = RoomRepository(bobClient); final bob = bobRepository.joinRoom(username: 'bob'); diff --git a/packages/supabase/example/web/main.dart b/packages/supabase/example/web/main.dart index 3eefaf359..11cf8abac 100644 --- a/packages/supabase/example/web/main.dart +++ b/packages/supabase/example/web/main.dart @@ -7,7 +7,11 @@ import 'package:web/web.dart' as web; void main() { const supabaseUrl = 'YOUR_SUPABASE_URL'; const supabaseKey = 'YOUR_SUPABASE_KEY'; - final supabase = SupabaseClient(supabaseUrl, supabaseKey); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + authOptions: AuthClientOptions(asyncStorage: MemoryAuthAsyncStorage()), + ); final element = web.document.querySelector('#output'); element?.textContent = 'Supabase Dart Web Example'; diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index 3f18e2695..abe5e4293 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -68,8 +68,8 @@ import 'trace_http_client.dart'; /// redirect to an OAuth provider. `MemoryAuthAsyncStorage` only suits flows /// that start and complete in the same process, such as tests and command line /// tools that keep a redirect listener open. Without an `asyncStorage` the -/// constructor throws an [ArgumentError], unless the implicit flow or an -/// [accessToken] is used. +/// constructor throws an [ArgumentError] for the pkce flow or a persisted +/// session, unless an [accessToken] is used. /// {@endtemplate} class SupabaseClient { /// {@macro supabase_client} @@ -104,12 +104,10 @@ class SupabaseClient { _jsonCodec = jsonCodec ?? (YAJsonIsolate()..initialize()), _ownsJsonCodec = jsonCodec == null { if (accessToken == null && - authOptions.authFlowType == AuthFlowType.pkce && - authOptions.asyncStorage == null) { - if (_ownsJsonCodec) { - unawaited(_jsonCodec.dispose()); - } - throw ArgumentError(_missingAsyncStorageMessage()); + authOptions.asyncStorage == null && + (authOptions.authFlowType == AuthFlowType.pkce || + authOptions.persistSession)) { + throw ArgumentError(_missingAsyncStorageMessage(authOptions)); } final baseHttpClient = httpClient ?? createDefaultHttpClient(); final tracedHttpClient = tracePropagationOptions == null @@ -498,14 +496,19 @@ class SupabaseClient { } } -String _missingAsyncStorageMessage() { - final message = - 'SupabaseClient uses the pkce flow by default, which needs an ' - 'asyncStorage to keep its code verifiers in. Pass one through ' - 'authOptions, for example ' - '`authOptions: AuthClientOptions(asyncStorage: ' - 'MemoryAuthAsyncStorage())` when the verifiers do not need to outlive ' - 'the process, or set `authFlowType: AuthFlowType.implicit`.'; +String _missingAsyncStorageMessage(AuthClientOptions authOptions) { + final message = authOptions.authFlowType == AuthFlowType.pkce + ? 'SupabaseClient uses the pkce flow by default, which needs an ' + 'asyncStorage to keep its code verifiers in. Pass one through ' + 'authOptions, for example ' + '`authOptions: AuthClientOptions(asyncStorage: ' + 'MemoryAuthAsyncStorage())` when the verifiers do not need to ' + 'outlive the process, or set ' + '`authFlowType: AuthFlowType.implicit`.' + : 'SupabaseClient needs an asyncStorage to persist the session in. Pass ' + 'one through authOptions, for example ' + '`authOptions: AuthClientOptions(asyncStorage: ' + 'MemoryAuthAsyncStorage())`, or set `persistSession: false`.'; if (Zone.current[#test.invoker] == null) { return message; } diff --git a/packages/supabase/test/client_test.dart b/packages/supabase/test/client_test.dart index ee97e7679..608adb8d1 100644 --- a/packages/supabase/test/client_test.dart +++ b/packages/supabase/test/client_test.dart @@ -242,6 +242,26 @@ void main() { ); }); + test('a persisted session throws when no asyncStorage is given', () { + expect( + () => real.SupabaseClient( + 'http://localhost:1', + 'supabaseKey', + authOptions: const AuthClientOptions( + authFlowType: AuthFlowType.implicit, + persistSession: true, + ), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('SupabaseClient needs an asyncStorage to persist'), + ), + ), + ); + }); + test('the implicit flow needs no asyncStorage', () async { final supabase = real.SupabaseClient( 'http://localhost:1', diff --git a/packages/supabase_auth/test/client_test.dart b/packages/supabase_auth/test/client_test.dart index 50601f76c..e5d3e0013 100644 --- a/packages/supabase_auth/test/client_test.dart +++ b/packages/supabase_auth/test/client_test.dart @@ -1002,6 +1002,26 @@ void main() { returnsNormally, ); }); + + test('throws an AuthException when exchanging a code', () async { + final client = AuthClient( + url: authUrl, + headers: {'apikey': anonToken}, + flowType: AuthFlowType.implicit, + ); + addTearDown(client.dispose); + + await expectLater( + () => client.exchangeCodeForSession('code'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Exchanging a code needs the asyncStorage'), + ), + ), + ); + }); }); }