diff --git a/packages/gotrue/example/main.dart b/packages/gotrue/example/main.dart index aef001b14..957855def 100644 --- a/packages/gotrue/example/main.dart +++ b/packages/gotrue/example/main.dart @@ -12,6 +12,9 @@ Future main() async { 'Authorization': 'Bearer $supabaseKey', 'apikey': supabaseKey, }, + // The pkce flow needs somewhere to keep its code verifiers. Swap this for + // a persistent storage when the code exchange can happen after a restart. + asyncStorage: MemoryGotrueAsyncStorage(), ); try { diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 52856cfd8..ff5583103 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -45,7 +45,8 @@ class _SessionState { /// [httpClient] custom http client. /// /// [asyncStorage] local storage to store pkce code verifiers. Required when -/// using the pkce flow. +/// using the pkce flow. Pass a [MemoryGotrueAsyncStorage] when the verifiers +/// do not need to outlive the process. /// /// Set [flowType] to [AuthFlowType.implicit] to perform old implicit auth flow. /// {@endtemplate} @@ -156,7 +157,13 @@ class GoTrueClient { Client? httpClient, GotrueAsyncStorage? asyncStorage, AuthFlowType flowType = AuthFlowType.pkce, - }) : _url = url ?? Constants.defaultGotrueUrl, + }) : assert( + flowType != AuthFlowType.pkce || asyncStorage != null, + 'You need to provide asyncStorage to perform pkce flow. Pass a ' + 'MemoryGotrueAsyncStorage when the code verifiers do not need to ' + 'outlive the process.', + ), + _url = url ?? Constants.defaultGotrueUrl, _headers = {...Constants.defaultHeaders, ...?headers}, _httpClient = httpClient, _asyncStorage = asyncStorage, diff --git a/packages/gotrue/lib/src/types/gotrue_async_storage.dart b/packages/gotrue/lib/src/types/gotrue_async_storage.dart index 29ce2b15d..864eca3a5 100644 --- a/packages/gotrue/lib/src/types/gotrue_async_storage.dart +++ b/packages/gotrue/lib/src/types/gotrue_async_storage.dart @@ -14,3 +14,26 @@ abstract class GotrueAsyncStorage { /// Removes an item asynchronously from the storage for the given key. Future removeItem({required String key}); } + +/// A [GotrueAsyncStorage] that keeps the pkce code verifiers in memory only. +/// +/// Everything it holds is lost when the process exits, so a pkce flow started +/// before a restart can no longer be completed. Use a persistent +/// implementation when the code exchange happens after the app was closed, +/// which is what `supabase_flutter` does with shared preferences. +class MemoryGotrueAsyncStorage extends GotrueAsyncStorage { + final _items = {}; + + @override + Future getItem({required String key}) async => _items[key]; + + @override + Future setItem({required String key, required String value}) async { + _items[key] = value; + } + + @override + Future removeItem({required String key}) async { + _items.remove(key); + } +} diff --git a/packages/gotrue/test/admin_delete_user_test.dart b/packages/gotrue/test/admin_delete_user_test.dart index 5dcecc651..07909ff33 100644 --- a/packages/gotrue/test/admin_delete_user_test.dart +++ b/packages/gotrue/test/admin_delete_user_test.dart @@ -4,6 +4,8 @@ import 'package:gotrue/gotrue.dart'; import 'package:http/http.dart'; import 'package:test/test.dart'; +import 'utils.dart'; + class _CapturingHttpClient extends BaseClient { Request? lastRequest; @@ -25,6 +27,7 @@ void main() { client = GoTrueClient( url: 'http://localhost:9999', httpClient: httpClient, + asyncStorage: TestAsyncStorage(), ); }); diff --git a/packages/gotrue/test/admin_list_users_test.dart b/packages/gotrue/test/admin_list_users_test.dart index 7db2b53ad..183bd6474 100644 --- a/packages/gotrue/test/admin_list_users_test.dart +++ b/packages/gotrue/test/admin_list_users_test.dart @@ -4,6 +4,8 @@ import 'package:gotrue/gotrue.dart'; import 'package:http/http.dart'; import 'package:test/test.dart'; +import 'utils.dart'; + /// Serves a fixed list-users response with the pagination headers the GoTrue /// server sends alongside it. class ListUsersMockClient extends BaseClient { @@ -49,6 +51,7 @@ void main() { GoTrueClient clientWith(ListUsersMockClient mockClient) => GoTrueClient( url: 'http://localhost:9999', httpClient: mockClient, + asyncStorage: TestAsyncStorage(), ); test('listUsers() returns the metadata of a middle page', () async { diff --git a/packages/gotrue/test/admin_test.dart b/packages/gotrue/test/admin_test.dart index 44febb805..3093afa0f 100644 --- a/packages/gotrue/test/admin_test.dart +++ b/packages/gotrue/test/admin_test.dart @@ -32,6 +32,7 @@ void main() { 'Authorization': 'Bearer ${getServiceRoleToken(env)}', 'apikey': getServiceRoleToken(env), }, + asyncStorage: TestAsyncStorage(), ); }); diff --git a/packages/gotrue/test/client_test.dart b/packages/gotrue/test/client_test.dart index a1200155c..8c05a9047 100644 --- a/packages/gotrue/test/client_test.dart +++ b/packages/gotrue/test/client_test.dart @@ -281,6 +281,7 @@ void main() { final newClient = GoTrueClient( url: gotrueUrl, headers: {'apikey': anonToken}, + asyncStorage: TestAsyncStorage(), ); expect(newClient.currentSession?.refreshToken ?? '', isEmpty); @@ -314,6 +315,7 @@ void main() { final newClient = GoTrueClient( url: gotrueUrl, headers: {'apikey': anonToken}, + asyncStorage: TestAsyncStorage(), ); expect(newClient.currentSession, isNull); @@ -357,6 +359,7 @@ void main() { final newClient = GoTrueClient( url: gotrueUrl, headers: {'apikey': anonToken}, + asyncStorage: TestAsyncStorage(), ); // Should fall back to _callRefreshToken and succeed. @@ -408,6 +411,7 @@ void main() { final newClient = GoTrueClient( url: gotrueUrl, headers: {'apikey': anonToken}, + asyncStorage: TestAsyncStorage(), ); expect(newClient.currentSession, isNull); @@ -643,7 +647,11 @@ void main() { late GoTrueClient client; setUpAll(() { - client = GoTrueClient(url: gotrueUrl, httpClient: CustomHttpClient()); + client = GoTrueClient( + url: gotrueUrl, + httpClient: CustomHttpClient(), + asyncStorage: TestAsyncStorage(), + ); }); test('signIn()', () async { @@ -673,7 +681,11 @@ void main() { setUpAll(() { httpClient = RetryTestHttpClient(); - client = GoTrueClient(url: gotrueUrl, httpClient: httpClient); + client = GoTrueClient( + url: gotrueUrl, + httpClient: httpClient, + asyncStorage: TestAsyncStorage(), + ); }); test('Session recovery succeeds after retries', () async { @@ -931,6 +943,32 @@ void main() { }, ); }); + + group('Constructing a client without an asyncStorage', () { + test('asserts when the pkce flow is used', () { + expect( + () => GoTrueClient(url: gotrueUrl, headers: {'apikey': anonToken}), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('You need to provide asyncStorage to perform pkce flow.'), + ), + ), + ); + }); + + test('is allowed when the implicit flow is used', () { + expect( + () => GoTrueClient( + url: gotrueUrl, + headers: {'apikey': anonToken}, + flowType: AuthFlowType.implicit, + ), + returnsNormally, + ); + }); + }); } /// Reads the email-change confirmation link that GoTrue delivered to diff --git a/packages/gotrue/test/header_isolation_test.dart b/packages/gotrue/test/header_isolation_test.dart index bffdf7890..cb7cc44f7 100644 --- a/packages/gotrue/test/header_isolation_test.dart +++ b/packages/gotrue/test/header_isolation_test.dart @@ -4,6 +4,8 @@ import 'package:gotrue/gotrue.dart'; import 'package:http/http.dart'; import 'package:test/test.dart'; +import 'utils.dart'; + /// Records the headers of every request it receives and always answers with a /// minimal user payload, so we can inspect what the client actually sent. class _RecordingHttpClient extends BaseClient { @@ -46,6 +48,7 @@ void main() { url: 'http://localhost', headers: {'apikey': 'anon-key'}, httpClient: http, + asyncStorage: TestAsyncStorage(), ); }); diff --git a/packages/gotrue/test/memory_async_storage_test.dart b/packages/gotrue/test/memory_async_storage_test.dart new file mode 100644 index 000000000..5ea9f00ed --- /dev/null +++ b/packages/gotrue/test/memory_async_storage_test.dart @@ -0,0 +1,36 @@ +import 'package:gotrue/gotrue.dart'; +import 'package:test/test.dart'; + +void main() { + late MemoryGotrueAsyncStorage storage; + + setUp(() { + storage = MemoryGotrueAsyncStorage(); + }); + + test('returns null for a key that was never stored', () async { + expect(await storage.getItem(key: 'code-verifier'), isNull); + }); + + test('returns the value that was stored last', () async { + await storage.setItem(key: 'code-verifier', value: 'first'); + await storage.setItem(key: 'code-verifier', value: 'second'); + + expect(await storage.getItem(key: 'code-verifier'), 'second'); + }); + + test('forgets a removed key', () async { + await storage.setItem(key: 'code-verifier', value: 'value'); + await storage.removeItem(key: 'code-verifier'); + + expect(await storage.getItem(key: 'code-verifier'), isNull); + }); + + test('keeps the entries of two instances apart', () async { + await storage.setItem(key: 'code-verifier', value: 'value'); + + final other = MemoryGotrueAsyncStorage(); + + expect(await other.getItem(key: 'code-verifier'), isNull); + }); +} diff --git a/packages/gotrue/test/mfa_enroll_test.dart b/packages/gotrue/test/mfa_enroll_test.dart index 97a16008a..87dd4fffc 100644 --- a/packages/gotrue/test/mfa_enroll_test.dart +++ b/packages/gotrue/test/mfa_enroll_test.dart @@ -4,6 +4,8 @@ import 'package:gotrue/gotrue.dart'; import 'package:http/http.dart'; import 'package:test/test.dart'; +import 'utils.dart'; + /// Records the body of every request it receives and answers with a minimal /// enroll payload, so we can inspect what the client actually sent without a /// live server. @@ -47,6 +49,7 @@ void main() { url: 'http://localhost', headers: {'apikey': 'anon-key'}, httpClient: http, + asyncStorage: TestAsyncStorage(), ); }); diff --git a/packages/gotrue/test/src/gotrue_admin_custom_providers_api_test.dart b/packages/gotrue/test/src/gotrue_admin_custom_providers_api_test.dart index ad00d2f9f..731766d02 100644 --- a/packages/gotrue/test/src/gotrue_admin_custom_providers_api_test.dart +++ b/packages/gotrue/test/src/gotrue_admin_custom_providers_api_test.dart @@ -57,6 +57,7 @@ void main() { 'apikey': serviceRoleToken, 'x-forwarded-for': '127.0.0.1', }, + asyncStorage: TestAsyncStorage(), ); }); diff --git a/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart b/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart index 5dd2ba75f..29f952439 100644 --- a/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart +++ b/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart @@ -33,6 +33,7 @@ void main() { 'apikey': serviceRoleToken, 'x-forwarded-for': '127.0.0.1', }, + asyncStorage: TestAsyncStorage(), ); }); diff --git a/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart b/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart index 3029a12f8..9af97d8da 100644 --- a/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart +++ b/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart @@ -33,6 +33,7 @@ void main() { 'apikey': serviceRoleToken, 'x-forwarded-for': '127.0.0.1', }, + asyncStorage: TestAsyncStorage(), ); }); diff --git a/packages/gotrue/test/src/gotrue_mfa_api_test.dart b/packages/gotrue/test/src/gotrue_mfa_api_test.dart index 139121e50..13a4eec07 100644 --- a/packages/gotrue/test/src/gotrue_mfa_api_test.dart +++ b/packages/gotrue/test/src/gotrue_mfa_api_test.dart @@ -61,6 +61,7 @@ void main() { 'apikey': anonToken, 'x-forwarded-for': '127.0.0.1', }, + asyncStorage: TestAsyncStorage(), ); }); diff --git a/packages/gotrue/test/src/gotrue_oauth_api_test.dart b/packages/gotrue/test/src/gotrue_oauth_api_test.dart index 8ab8135fc..e77e8bbdc 100644 --- a/packages/gotrue/test/src/gotrue_oauth_api_test.dart +++ b/packages/gotrue/test/src/gotrue_oauth_api_test.dart @@ -310,6 +310,7 @@ class GotrueOauthApiFixture { 'apikey': _serviceRoleToken, 'x-forwarded-for': '127.0.0.1', }, + asyncStorage: TestAsyncStorage(), ); } diff --git a/packages/gotrue/test/utils.dart b/packages/gotrue/test/utils.dart index 2775c393f..e99ce0b9d 100644 --- a/packages/gotrue/test/utils.dart +++ b/packages/gotrue/test/utils.dart @@ -81,20 +81,4 @@ const sessionDataUserId = '4d2583da-8de4-49d3-9cd1-37a9a74f55bd'; return (accessToken: accessToken, sessionString: sessionString); } -class TestAsyncStorage extends GotrueAsyncStorage { - final Map _map = {}; - @override - Future getItem({required String key}) async { - return _map[key]; - } - - @override - Future removeItem({required String key}) async { - _map.remove(key); - } - - @override - Future setItem({required String key, required String value}) async { - _map[key] = value; - } -} +class TestAsyncStorage extends MemoryGotrueAsyncStorage {} diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index ab9c132cc..67a8ecf41 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -44,9 +44,9 @@ import 'trace_http_client.dart'; /// Pass an instance of `YAJsonIsolate` to [isolate] to use your own persisted /// isolate instance. A new instance will be created if [isolate] is omitted. /// -/// Pass an instance of `GotrueAsyncStorage` to the `pkceAsyncStorage` field of -/// [authOptions] and set its `authFlowType` field to `AuthFlowType.pkce` in -/// order to perform auth actions with pkce flow. +/// The pkce flow is used by default. It stores its code verifiers in +/// `AuthClientOptions.pkceAsyncStorage`, which falls back to an in-memory +/// storage when none is given. /// {@endtemplate} class SupabaseClient { final String _supabaseKey; @@ -331,7 +331,11 @@ class SupabaseClient { headers: authHeaders, autoRefreshToken: autoRefreshToken, httpClient: _gotrueHttpClient, - asyncStorage: gotrueAsyncStorage, + asyncStorage: + gotrueAsyncStorage ?? + (authFlowType == AuthFlowType.pkce + ? MemoryGotrueAsyncStorage() + : null), flowType: authFlowType, ); } diff --git a/packages/supabase/lib/src/supabase_client_options.dart b/packages/supabase/lib/src/supabase_client_options.dart index 5079ecbd3..cd13c7de1 100644 --- a/packages/supabase/lib/src/supabase_client_options.dart +++ b/packages/supabase/lib/src/supabase_client_options.dart @@ -34,6 +34,13 @@ class PostgrestClientOptions { class AuthClientOptions { final bool autoRefreshToken; + + /// Storage for the code verifiers of the pkce flow. + /// + /// Defaults to a [MemoryGotrueAsyncStorage], which only supports flows that + /// start and complete within the same process. Pass a persistent + /// implementation when the code is exchanged after a restart, which is what + /// `supabase_flutter` does with shared preferences. final GotrueAsyncStorage? pkceAsyncStorage; final AuthFlowType authFlowType; diff --git a/packages/supabase/test/client_test.dart b/packages/supabase/test/client_test.dart index 8a62859ac..5b8a5983f 100644 --- a/packages/supabase/test/client_test.dart +++ b/packages/supabase/test/client_test.dart @@ -183,6 +183,17 @@ void main() { }); group('auth', () { + test('the pkce flow works without passing a pkceAsyncStorage', () async { + final supabase = SupabaseClient('http://localhost:1', 'supabaseKey'); + addTearDown(supabase.dispose); + + final response = await supabase.auth.getOAuthSignInUrl( + provider: OAuthProvider.github, + ); + + expect(response.url, contains('code_challenge=')); + }); + test('properly set Authorization header', () async { final (:sessionString, :accessToken) = getSessionData( DateTime.now().add(Duration(hours: 1)), diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 70c0d4dda..64552f238 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -1982,6 +1982,7 @@ features: symbols: - GotrueAsyncStorage - LocalStorage + - MemoryGotrueAsyncStorage supporting_symbols: - AuthClientOptions.pkceAsyncStorage - EmptyLocalStorage @@ -2002,6 +2003,9 @@ features: - LocalStorage.initialize - LocalStorage.persistSession - LocalStorage.removePersistedSession + - MemoryGotrueAsyncStorage.getItem + - MemoryGotrueAsyncStorage.removeItem + - MemoryGotrueAsyncStorage.setItem - SharedPreferencesGotrueAsyncStorage - SharedPreferencesGotrueAsyncStorage.SharedPreferencesGotrueAsyncStorage - SharedPreferencesGotrueAsyncStorage.getItem