From 5d343f51147e8557e72520d95e99c7c2b9069022 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 22 Jun 2026 10:05:06 +0200 Subject: [PATCH 1/5] fix(gotrue): use captcha_token key in verifyOTP and parse resend message id verifyOTP sent the captcha token under `captchaToken` inside `gotrue_meta_security`, but the server reads `captcha_token`, so the token was silently dropped. resend checked `containsKey(['message_id'])` with a list argument, which is always false, so `ResendResponse.messageId` was never populated. --- packages/gotrue/lib/src/gotrue_client.dart | 4 ++-- packages/gotrue/test/otp_mock_test.dart | 23 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 85af970e1..fbe65f86b 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -595,7 +595,7 @@ class GoTrueClient { if (token != null) 'token': token, 'type': type.snakeCase, 'redirect_to': redirectTo, - 'gotrue_meta_security': {'captchaToken': captchaToken}, + 'gotrue_meta_security': {'captcha_token': captchaToken}, if (tokenHash != null) 'token_hash': tokenHash, }; final fetchOptions = GotrueRequestOptions(headers: _headers, body: body); @@ -765,7 +765,7 @@ class GoTrueClient { options: options, ); - if ((response as Map).containsKey(['message_id'])) { + if ((response as Map).containsKey('message_id')) { return ResendResponse(messageId: response['message_id']); } return ResendResponse(); diff --git a/packages/gotrue/test/otp_mock_test.dart b/packages/gotrue/test/otp_mock_test.dart index 41f284636..c27bc1c50 100644 --- a/packages/gotrue/test/otp_mock_test.dart +++ b/packages/gotrue/test/otp_mock_test.dart @@ -244,6 +244,15 @@ void main() { expect(response, isA()); }); + test('resend() parses the message id from the response', () async { + final response = await client.resend( + phone: testPhone, + type: OtpType.sms, + ); + + expect(response.messageId, 'mock-message-id-resend'); + }); + test('resend() with email type', () async { final response = await client.resend( email: testEmail, @@ -610,5 +619,19 @@ void main() { ); expect(mockClient.lastRequestBody?['type'], 'email_change'); }); + + test('verifyOTP() sends the captcha token under the captcha_token key', + () async { + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.sms, + captchaToken: 'captcha-token-123', + ); + + final security = mockClient.lastRequestBody?['gotrue_meta_security'] + as Map?; + expect(security?['captcha_token'], 'captcha-token-123'); + }); }); } From a894b646140a4319d0856f4fdb34fb45bb95d1c3 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 22 Jun 2026 10:05:06 +0200 Subject: [PATCH 2/5] fix(postgrest): handle maybeSingle with count when zero rows match When `.maybeSingle().count()` matched zero rows and no converter was set, the builder returned `null as T` where `T` is the non-nullable `PostgrestResponse`, throwing a TypeError. It now returns a response with null data and a count of 0, matching the converter branch. --- .../postgrest/lib/src/postgrest_builder.dart | 2 +- .../postgrest/test/maybe_single_test.dart | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 packages/postgrest/test/maybe_single_test.dart diff --git a/packages/postgrest/lib/src/postgrest_builder.dart b/packages/postgrest/lib/src/postgrest_builder.dart index 96d6b6fb2..f24881e48 100644 --- a/packages/postgrest/lib/src/postgrest_builder.dart +++ b/packages/postgrest/lib/src/postgrest_builder.dart @@ -366,7 +366,7 @@ class PostgrestBuilder implements Future { return PostgrestResponse(data: _converter(null as R), count: 0) as T; } - return null as T; + return PostgrestResponse(data: null as S, count: 0) as T; } if (_converter != null) { return _converter(null as R) as T; diff --git a/packages/postgrest/test/maybe_single_test.dart b/packages/postgrest/test/maybe_single_test.dart new file mode 100644 index 000000000..d61ee0959 --- /dev/null +++ b/packages/postgrest/test/maybe_single_test.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:postgrest/postgrest.dart'; +import 'package:test/test.dart'; + +/// Mimics PostgREST returning the "0 rows" error that `maybeSingle()` treats as +/// an empty result rather than a failure. +class ZeroRowsHttpClient extends BaseClient { + @override + Future send(BaseRequest request) async { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({ + 'code': 'PGRST116', + 'details': 'Results contain 0 rows', + 'hint': null, + 'message': 'JSON object requested, multiple (or no) rows returned', + }))), + 406, + request: request, + ); + } +} + +void main() { + test('maybeSingle().count() returns null data and count 0 when no rows match', + () async { + final postgrest = PostgrestClient( + 'https://example.com', + httpClient: ZeroRowsHttpClient(), + ); + + final response = await postgrest + .from('users') + .update({'name': 'x'}) + .select() + .maybeSingle() + .count(); + + expect(response.data, isNull); + expect(response.count, 0); + }); +} From bb99b34cf20061af46852128f4b6541ba6e26f5e Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 22 Jun 2026 10:05:06 +0200 Subject: [PATCH 3/5] fix(storage_client): fix upload retry, signed url content type and path normalization A single MultipartFile instance was reused across retry attempts, so a retry re-finalized an already finalized file and threw a StateError instead of retrying. The request factory now builds a fresh MultipartFile per attempt. Binary signed url uploads derived the content type from the full url including the query string, which defeated mime lookup and fell back to application/octet-stream; it now uses the url path. _removeEmptyFolders used JavaScript regex literals that never matched, so it was a no-op; it now uses valid Dart patterns. --- packages/storage_client/lib/src/fetch.dart | 35 +++--- .../lib/src/storage_file_api.dart | 4 +- packages/storage_client/test/fetch_test.dart | 104 ++++++++++++++++++ 3 files changed, 122 insertions(+), 21 deletions(-) create mode 100644 packages/storage_client/test/fetch_test.dart diff --git a/packages/storage_client/lib/src/fetch.dart b/packages/storage_client/lib/src/fetch.dart index 64556563c..a688b9421 100644 --- a/packages/storage_client/lib/src/fetch.dart +++ b/packages/storage_client/lib/src/fetch.dart @@ -107,16 +107,16 @@ class Fetch { final contentType = fileOptions.contentType != null ? MediaType.parse(fileOptions.contentType!) : _parseMediaType(file.path); - final multipartFile = http.MultipartFile.fromBytes( - '', - file.readAsBytesSync(), - filename: file.path, - contentType: contentType, - ); + final bytes = file.readAsBytesSync(); return _handleMultipartRequest( method, url, - multipartFile, + () => http.MultipartFile.fromBytes( + '', + bytes, + filename: file.path, + contentType: contentType, + ), fileOptions, options, retryAttempts, @@ -135,18 +135,17 @@ class Fetch { ) { final contentType = fileOptions.contentType != null ? MediaType.parse(fileOptions.contentType!) - : _parseMediaType(url); - final multipartFile = http.MultipartFile.fromBytes( - '', - data, - // request fails with null filename so set it empty instead. - filename: '', - contentType: contentType, - ); + : _parseMediaType(Uri.parse(url).path); return _handleMultipartRequest( method, url, - multipartFile, + () => http.MultipartFile.fromBytes( + '', + data, + // request fails with null filename so set it empty instead. + filename: '', + contentType: contentType, + ), fileOptions, options, retryAttempts, @@ -157,7 +156,7 @@ class Fetch { Future _handleMultipartRequest( String method, String url, - MultipartFile multipartFile, + MultipartFile Function() createMultipartFile, FileOptions fileOptions, FetchOptions? options, int retryAttempts, @@ -169,7 +168,7 @@ class Fetch { http.MultipartRequest createRequest() { final request = http.MultipartRequest(method, Uri.parse(url)) ..headers.addAll(headers) - ..files.add(multipartFile) + ..files.add(createMultipartFile()) ..fields['cacheControl'] = fileOptions.cacheControl ..headers['x-upsert'] = fileOptions.upsert.toString(); if (fileOptions.metadata != null) { diff --git a/packages/storage_client/lib/src/storage_file_api.dart b/packages/storage_client/lib/src/storage_file_api.dart index b01595f15..400606d06 100644 --- a/packages/storage_client/lib/src/storage_file_api.dart +++ b/packages/storage_client/lib/src/storage_file_api.dart @@ -41,9 +41,7 @@ class StorageFileApi { } String _removeEmptyFolders(String path) { - return path - .replaceAll(RegExp(r'/^\/|\/$/g'), '') - .replaceAll(RegExp(r'/\/+/g'), '/'); + return path.replaceAll(RegExp(r'^/|/$'), '').replaceAll(RegExp(r'/+'), '/'); } /// Uploads a file to an existing bucket. diff --git a/packages/storage_client/test/fetch_test.dart b/packages/storage_client/test/fetch_test.dart new file mode 100644 index 000000000..9d97ce446 --- /dev/null +++ b/packages/storage_client/test/fetch_test.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart'; +import 'package:storage_client/storage_client.dart'; +import 'package:test/test.dart'; + +import 'custom_http_client.dart'; + +const storageUrl = 'http://localhost/storage/v1'; +const headers = {'Authorization': 'Bearer token'}; + +/// Client that finalizes (reads) the request body before failing, mimicking a +/// real HTTP client. This exercises [MultipartFile] finalization on every retry +/// attempt, which previously crashed because the same file instance was reused. +class FinalizingRetryHttpClient extends BaseClient { + FinalizingRetryHttpClient({this.failuresBeforeSuccess = 1}); + + final int failuresBeforeSuccess; + int attempts = 0; + + @override + Future send(BaseRequest request) async { + attempts++; + await request.finalize().drain(); + if (attempts <= failuresBeforeSuccess) { + throw ClientException('Offline'); + } + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'Key': 'public/a.txt'}))), + 201, + request: request, + ); + } +} + +void main() { + group('multipart uploads', () { + test('retries a binary upload after a failure that finalized the request', + () async { + final retryClient = FinalizingRetryHttpClient(failuresBeforeSuccess: 1); + final client = SupabaseStorageClient( + storageUrl, + headers, + httpClient: retryClient, + retryAttempts: 3, + ); + + final result = await client + .from('bucket') + .uploadBinary('folder/file.png', Uint8List.fromList([1, 2, 3])); + + expect(result, 'public/a.txt'); + expect(retryClient.attempts, 2); + }); + + test('detects content type from the path of a binary signed url upload', + () async { + final mockClient = CustomHttpClient(); + mockClient.response = {}; + mockClient.statusCode = 200; + final client = SupabaseStorageClient( + storageUrl, + headers, + httpClient: mockClient, + ); + + await client.from('bucket').uploadBinaryToSignedUrl( + 'folder/image.png', + 'signed-token', + Uint8List.fromList([1, 2, 3]), + ); + + final request = mockClient.receivedRequests.single as MultipartRequest; + expect(request.files.single.contentType.mimeType, 'image/png'); + }); + }); + + group('path normalization', () { + test('removes leading, trailing and duplicate slashes from the path', + () async { + final mockClient = CustomHttpClient(); + mockClient.response = {}; + mockClient.statusCode = 200; + final client = SupabaseStorageClient( + storageUrl, + headers, + httpClient: mockClient, + ); + + final cleanPath = await client.from('bucket').uploadBinaryToSignedUrl( + '/folder//image.png/', + 'signed-token', + Uint8List.fromList([1]), + ); + + expect(cleanPath, 'folder/image.png'); + + final requestPath = mockClient.receivedRequests.single.url.path; + expect(requestPath, endsWith('/bucket/folder/image.png')); + expect(requestPath.contains('//'), isFalse); + }); + }); +} From e8a2104df14113ec4ab804810087bf4a2f4104e9 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 22 Jun 2026 10:05:06 +0200 Subject: [PATCH 4/5] fix(supabase): preserve apikey on realtime headers when updating client headers The headers setter rebuilt the realtime client headers from the client headers only, dropping the apikey entry that is present at construction. It now includes the apikey, matching how the realtime client is built. Also removes a redundant self-spread when merging rest headers before an rpc. --- packages/supabase/lib/src/supabase_client.dart | 7 +++++-- packages/supabase/lib/src/supabase_query_schema.dart | 2 +- packages/supabase/test/client_test.dart | 7 +++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index dbd9d2333..9f62faf86 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -113,7 +113,10 @@ class SupabaseClient { // manually unsubscribe and resubscribe to all channels. realtime.headers ..clear() - ..addAll(_headers); + ..addAll({ + 'apikey': _supabaseKey, + ..._headers, + }); } /// {@macro supabase_client} @@ -213,7 +216,7 @@ class SupabaseClient { Map? params, get = false, }) { - rest.headers.addAll({...rest.headers, ...headers}); + rest.headers.addAll(headers); return rest.rpc(fn, params: params, get: get); } diff --git a/packages/supabase/lib/src/supabase_query_schema.dart b/packages/supabase/lib/src/supabase_query_schema.dart index 3647ccf9a..a898f71c7 100644 --- a/packages/supabase/lib/src/supabase_query_schema.dart +++ b/packages/supabase/lib/src/supabase_query_schema.dart @@ -54,7 +54,7 @@ class SupabaseQuerySchema { Map? params, bool get = false, }) { - _rest.headers.addAll({..._rest.headers, ..._headers}); + _rest.headers.addAll(_headers); return _rest.rpc( fn, params: params, diff --git a/packages/supabase/test/client_test.dart b/packages/supabase/test/client_test.dart index d8228ff5c..e51672aa0 100644 --- a/packages/supabase/test/client_test.dart +++ b/packages/supabase/test/client_test.dart @@ -305,6 +305,13 @@ void main() { expect(supabase.headers['X-Client-Info'], startsWith('supabase-dart/')); }); + test('should preserve apikey on realtime headers when setting headers', + () { + supabase.headers = {'Custom-Header': 'custom-value'}; + + expect(supabase.realtime.headers['apikey'], supabaseKey); + }); + test('should not update auth headers when using custom access token', () { final customTokenClient = SupabaseClient( supabaseUrl, From 88c35c65c341dcb79b95915378deb9c356a76ecf Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 22 Jun 2026 10:05:06 +0200 Subject: [PATCH 5/5] fix(supabase_flutter): prevent dispose crash with custom accessToken When initialized with a custom accessToken, _restoreSessionCancellableOperation was never assigned, so dispose threw a LateInitializationError. The field is now nullable and cancelled with a null-aware call. --- .../supabase_flutter/lib/src/supabase.dart | 7 +++-- .../supabase_flutter/test/dispose_test.dart | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 packages/supabase_flutter/test/dispose_test.dart diff --git a/packages/supabase_flutter/lib/src/supabase.dart b/packages/supabase_flutter/lib/src/supabase.dart index e5dfac3f8..c50676880 100644 --- a/packages/supabase_flutter/lib/src/supabase.dart +++ b/packages/supabase_flutter/lib/src/supabase.dart @@ -176,7 +176,10 @@ class Supabase { bool _debugEnable = false; /// Wraps the `recoverSession()` call so that it can be terminated when `dispose()` is called - late CancelableOperation _restoreSessionCancellableOperation; + /// + /// Only set when [Supabase.initialize] is called without a custom + /// `accessToken`, since session recovery is skipped for third-party auth. + CancelableOperation? _restoreSessionCancellableOperation; // Listener for app lifecycle events to handle Realtime reconnection. AppLifecycleListener? _lifecycleListener; @@ -195,7 +198,7 @@ class Supabase { /// Dispose the instance to free up resources. Future dispose() async { _targetLifecycleState = null; - await _restoreSessionCancellableOperation.cancel(); + await _restoreSessionCancellableOperation?.cancel(); _logSubscription?.cancel(); client.dispose(); _instance._supabaseAuth?.dispose(); diff --git a/packages/supabase_flutter/test/dispose_test.dart b/packages/supabase_flutter/test/dispose_test.dart new file mode 100644 index 000000000..bd9620680 --- /dev/null +++ b/packages/supabase_flutter/test/dispose_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'widget_test_stubs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // This must be the first (and only) initialization in this isolate. The + // `Supabase` singleton keeps `_restoreSessionCancellableOperation` across + // initialize/dispose cycles, so any earlier initialization without a custom + // `accessToken` would assign it and mask the regression this test guards. + test('dispose() does not throw when initialized with a custom access token', + () async { + SharedPreferences.setMockInitialValues({}); + mockAppLink(); + + await Supabase.initialize( + url: '', + publishableKey: '', + accessToken: () async => 'custom-access-token', + ); + + await expectLater(Supabase.instance.dispose(), completes); + }); +}