From c597f883e2a7fc339b85f1c882ec26949a82cbac Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 16 Oct 2026 14:53:58 +0200 Subject: [PATCH] fix(auth)!: throw when a client has no asyncStorage for the pkce flow --- MIGRATION.md | 25 +++++++++ .../supabase/lib/src/supabase_client.dart | 34 ++++++++++-- packages/supabase/test/client_test.dart | 52 +++++++++++++++++-- .../supabase_auth/lib/src/auth_client.dart | 46 ++++++++-------- packages/supabase_auth/test/client_test.dart | 24 +++++++-- 5 files changed, 150 insertions(+), 31 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index d67d51f09..8a25de961 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -28,6 +28,8 @@ explicitly: - [`RealtimeClient.connectionState` is now typed](#realtimeclientconnectionstate-is-now-typed) - [`HttpMethod` is one shared enum](#httpmethod-is-one-shared-enum), where the enum `index` shifted - [Confirming an email or phone change emits `userUpdated`](#confirming-an-email-or-phone-change-emits-userupdated) +- [A client without an `asyncStorage` throws when it is created](#a-client-without-an-asyncstorage-throws-when-it-is-created), + where release builds used to skip the assert - [`AuthChangeEvent.initialSession` is emitted to every subscriber](#authchangeeventinitialsession-is-emitted-to-every-subscriber) - [The retry backoff defaults are the same in every client](#the-retry-backoff-defaults-are-the-same-in-every-client) - [The rest client and its builders are stateless](#the-rest-client-and-its-builders-are-stateless), @@ -531,6 +533,29 @@ verifier keys and names the channel that keeps the tabs of a web app in sync. Ve old `supabase.auth.token-…` prefix are still read and cleaned up, so a sign-in link requested before the upgrade still completes after it. +### A client without an `asyncStorage` throws when it is created + +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`. + +`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: + +```dart +final client = SupabaseClient( + url, + publishableKey, + authOptions: AuthClientOptions(asyncStorage: MemoryAuthAsyncStorage()), +); +``` + +`MemoryAuthAsyncStorage` only suits flows that start and complete in the same process. In tests, +`testSupabaseClient` from `supabase_test` already configures one. A client with a third-party +`accessToken` does not use the auth client, so it needs no `asyncStorage`. + ### `AuthChangeEvent.initialSession` is emitted to every subscriber `initialSession` is emitted to every new subscriber of `onAuthStateChange` as its first event, diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index 9fcd14965..3f18e2695 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -67,7 +67,9 @@ import 'trace_http_client.dart'; /// before the code comes back, which covers every email link and every /// 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. +/// tools that keep a redirect listener open. Without an `asyncStorage` the +/// constructor throws an [ArgumentError], unless the implicit flow or an +/// [accessToken] is used. /// {@endtemplate} class SupabaseClient { /// {@macro supabase_client} @@ -101,6 +103,14 @@ class SupabaseClient { _httpClient = httpClient, _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()); + } final baseHttpClient = httpClient ?? createDefaultHttpClient(); final tracedHttpClient = tracePropagationOptions == null ? baseHttpClient @@ -110,7 +120,9 @@ class SupabaseClient { supabaseUrl, ); _authApiHttpClient = tracedHttpClient; - _authInstance = _initSupabaseAuthClient(authOptions); + if (accessToken == null) { + _authInstance = _initSupabaseAuthClient(authOptions); + } _authHttpClient = AuthHttpClient( _supabaseKey, tracedHttpClient, @@ -368,7 +380,7 @@ class SupabaseClient { autoRefreshToken: authOptions.autoRefreshToken, httpClient: _authApiHttpClient, asyncStorage: authOptions.asyncStorage, - persistSession: accessToken == null && authOptions.persistSession, + persistSession: authOptions.persistSession, storageKey: authOptions.storageKey, flowType: authOptions.authFlowType, appendPkceFlowIdToRedirects: authOptions.appendPkceFlowIdToRedirects, @@ -485,3 +497,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`.'; + if (Zone.current[#test.invoker] == null) { + return message; + } + return '$message In a test, `testSupabaseClient` from ' + 'package:supabase_test builds a client with the storage already ' + 'configured.'; +} diff --git a/packages/supabase/test/client_test.dart b/packages/supabase/test/client_test.dart index 12961ea5e..ee97e7679 100644 --- a/packages/supabase/test/client_test.dart +++ b/packages/supabase/test/client_test.dart @@ -207,19 +207,65 @@ void main() { }); group('auth', () { - test('the pkce flow asserts when no asyncStorage is given', () { + test('the pkce flow throws when no asyncStorage is given', () { expect( () => real.SupabaseClient('http://localhost:1', 'supabaseKey'), throwsA( - isA().having( + isA().having( (error) => error.message, 'message', - contains('You need to provide asyncStorage to perform pkce flow.'), + allOf( + contains('SupabaseClient uses the pkce flow by default'), + contains( + 'authOptions: AuthClientOptions(asyncStorage: ' + 'MemoryAuthAsyncStorage())', + ), + contains('testSupabaseClient'), + ), ), ), ); }); + test('the testSupabaseClient hint is left out outside of a test', () { + expect( + () => Zone.root.run( + () => real.SupabaseClient('http://localhost:1', 'supabaseKey'), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + isNot(contains('testSupabaseClient')), + ), + ), + ); + }); + + test('the implicit flow needs no asyncStorage', () async { + final supabase = real.SupabaseClient( + 'http://localhost:1', + 'supabaseKey', + authOptions: const AuthClientOptions( + authFlowType: AuthFlowType.implicit, + ), + ); + addTearDown(supabase.dispose); + + expect(supabase.auth.currentSession, isNull); + }); + + test('a third-party accessToken needs no asyncStorage', () async { + final supabase = real.SupabaseClient( + 'http://localhost:1', + 'supabaseKey', + accessToken: () async => 'jwt', + ); + addTearDown(supabase.dispose); + + expect(() => supabase.auth, throwsA(isA())); + }); + test('the pkce flow works with a MemoryAuthAsyncStorage', () async { final supabase = real.SupabaseClient( 'http://localhost:1', diff --git a/packages/supabase_auth/lib/src/auth_client.dart b/packages/supabase_auth/lib/src/auth_client.dart index 950b685bd..999426c85 100644 --- a/packages/supabase_auth/lib/src/auth_client.dart +++ b/packages/supabase_auth/lib/src/auth_client.dart @@ -47,7 +47,8 @@ class _SessionState { /// [httpClient] custom http client. /// /// [asyncStorage] storage for the session and the pkce code verifiers. -/// Required when using the pkce flow or persisting the session. Pass a +/// Required when using the pkce flow or persisting the session, the +/// constructor throws an [ArgumentError] otherwise. Pass a /// [MemoryAuthAsyncStorage] when neither needs to outlive the process. /// /// [persistSession] whether the session is written to [asyncStorage] whenever @@ -95,17 +96,7 @@ class AuthClient { this.appendPkceFlowIdToRedirects = false, this.retryOptions = const SupabaseRetryOptions(count: 8), Duration? requestTimeout, - }) : assert( - flowType != AuthFlowType.pkce || asyncStorage != null, - 'You need to provide asyncStorage to perform pkce flow. Pass a ' - 'MemoryAuthAsyncStorage when the code verifiers do not need to ' - 'outlive the process.', - ), - assert( - !persistSession || asyncStorage != null, - 'You need to provide asyncStorage to persist the session.', - ), - _url = url ?? AuthConstants.defaultAuthUrl, + }) : _url = url ?? AuthConstants.defaultAuthUrl, _headers = {...AuthConstants.defaultHeaders, ...?headers}, _httpClient = httpClient, _requestTimeout = requestTimeout, @@ -115,6 +106,18 @@ class AuthClient { storageKey ?? defaultPersistSessionKey(url ?? AuthConstants.defaultAuthUrl), _flowType = flowType { + if (flowType == AuthFlowType.pkce && asyncStorage == null) { + throw ArgumentError( + 'The pkce flow needs an asyncStorage to keep its code verifiers in. ' + 'Pass one, such as a MemoryAuthAsyncStorage when the verifiers do not ' + 'need to outlive the process, or use AuthFlowType.implicit.', + ); + } + if (persistSession && asyncStorage == null) { + throw ArgumentError( + 'persistSession needs an asyncStorage to persist the session in.', + ); + } _pkceVerifierStore = asyncStorage == null ? null : PKCEVerifierStore(asyncStorage, storageKey: _storageKey); @@ -664,10 +667,13 @@ class AuthClient { String authCode, { String? flowId, }) async { - assert( - _pkceVerifierStore != null, - 'You need to provide asyncStorage to perform pkce flow.', - ); + final pkceVerifierStore = _pkceVerifierStore; + if (pkceVerifierStore == null) { + throw AuthException( + 'Exchanging a code needs the asyncStorage the pkce flow keeps its ' + 'code verifiers in.', + ); + } final requestedFlowId = PKCEVerifierStore.validateFlowId(flowId); if (flowId != null && requestedFlowId == null) { @@ -679,7 +685,7 @@ class AuthClient { ); } - final codeVerifierRawString = await _pkceVerifierStore!.retrieve( + final codeVerifierRawString = await pkceVerifierStore.retrieve( flowId: requestedFlowId, ); if (codeVerifierRawString == null) { @@ -699,7 +705,7 @@ class AuthClient { ), ); - await _pkceVerifierStore.remove(flowId: requestedFlowId); + await pkceVerifierStore.remove(flowId: requestedFlowId); final authSessionUrlResponse = AuthSessionUrlResponse( session: Session.fromJson(response)!, @@ -728,10 +734,6 @@ class AuthClient { if (_flowType != AuthFlowType.pkce) { return null; } - assert( - _pkceVerifierStore != null, - 'You need to provide asyncStorage to perform pkce flow.', - ); final codeVerifier = generatePKCEVerifier(); final flowId = PKCEVerifierStore.generateFlowId(); final evicted = await _pkceVerifierStore!.store( diff --git a/packages/supabase_auth/test/client_test.dart b/packages/supabase_auth/test/client_test.dart index b7acd447a..50601f76c 100644 --- a/packages/supabase_auth/test/client_test.dart +++ b/packages/supabase_auth/test/client_test.dart @@ -961,14 +961,32 @@ void main() { }); group('Constructing a client without an asyncStorage', () { - test('asserts when the pkce flow is used', () { + test('throws when the pkce flow is used', () { expect( () => AuthClient(url: authUrl, headers: {'apikey': anonToken}), throwsA( - isA().having( + isA().having( (error) => error.message, 'message', - contains('You need to provide asyncStorage to perform pkce flow.'), + contains('The pkce flow needs an asyncStorage'), + ), + ), + ); + }); + + test('throws when the session is persisted', () { + expect( + () => AuthClient( + url: authUrl, + headers: {'apikey': anonToken}, + flowType: AuthFlowType.implicit, + persistSession: true, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('persistSession needs an asyncStorage'), ), ), );