Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ melos version

- **GoTrueClient** manages sessions, tokens, and JWT validation
- Emits auth state changes via `Stream<AuthState>`
- **supabase_flutter** adds session persistence via `SharedPreferences` (mobile) or browser localStorage (web)
- **supabase_flutter** adds session persistence via `SharedPreferencesAsync` (mobile) or browser localStorage (web)
- Deep link handling for OAuth callbacks (detects `?code=` for PKCE or `#access_token=` for implicit flow)
- Auth tokens are automatically injected into all HTTP requests via `AuthHttpClient`
- Realtime client receives token updates when auth state changes
Expand Down Expand Up @@ -169,7 +169,7 @@ import './local_storage_stub.dart'
```

This enables:
- Native mobile (iOS/Android): SharedPreferences for persistence
- Native mobile (iOS/Android): SharedPreferencesAsync for persistence
- Web: Browser localStorage
- Single codebase for all platforms

Expand Down
143 changes: 143 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,146 @@ the server to restore both.

The `ApiVersions` class, and its `ApiVersions.v20240101` field, are removed along with it. Nothing
replaces them; they only existed to drive the comparison above.

### The session is persisted with `SharedPreferencesAsync`

`SharedPreferencesLocalStorage` and `SharedPreferencesGotrueAsyncStorage`, the storage
implementations `Supabase.initialize` uses by default, wrote through the legacy
[`SharedPreferences`](https://pub.dev/packages/shared_preferences#sharedpreferences-vs-sharedpreferencesasync-vs-sharedpreferenceswithcache)
API. They now use `SharedPreferencesAsync`. On web the session still goes into
`window.localStorage` under the same key as before, so nothing changes there.

The two APIs do not share a store on every platform, and on the ones where they do the legacy API
prefixes its keys, so a session written by v2 is invisible to the new one. `initialize()` therefore
moves an existing session over to `SharedPreferencesAsync` the first time it runs and deletes the
legacy entry, so your users stay signed in. No code change is needed for this, and there is nothing
to migrate if you already pass your own `LocalStorage`.

What this does mean is that the SDK no longer holds up its end of a mixed setup, and mixing is
worse than it first looks. How the two APIs relate depends on the platform:

| Platform | Relationship between the two APIs |
| --- | --- |
| Windows, Linux | One `shared_preferences.json`, rewritten in full from each API's own cache, so a write through either one can drop what the other wrote |
| Android | Separate stores, `SharedPreferences` against DataStore, so a value written through one is invisible to the other |
| iOS, macOS, web | One store, but the legacy API prefixes its keys with `flutter.`, so a value written through one is invisible to the other |

Only the first row loses data, and it loses it in both directions. That is what made sessions go
missing in v2, and from v3 on the same collision runs the other way: a session write by the SDK can
drop preferences your own code wrote through the legacy API. So if your code still calls
`SharedPreferences.getInstance()`, this is the moment to
[migrate it to `SharedPreferencesAsync`](https://pub.dev/packages/shared_preferences#migrating-from-sharedpreferences-to-sharedpreferencesasync-or-sharedpreferenceswithcache)
as well. The snippet below is the way out if you cannot do that yet.

If you would rather keep the session in the legacy store for now, pass a `LocalStorage` that reads
and writes it. Supplying your own storage is also where the session key comes in: `initialize()`
derives it from your project URL for the default storage, so you only name the key when you
construct a `LocalStorage` yourself, and `defaultPersistSessionKey` hands you the same one.

```dart
class LegacySharedPreferencesLocalStorage extends LocalStorage {
LegacySharedPreferencesLocalStorage({required this.persistSessionKey});

final String persistSessionKey;

late final SharedPreferences _preferences;

@override
Future<void> initialize() async {
_preferences = await SharedPreferences.getInstance();
}

@override
Future<bool> hasAccessToken() async =>
_preferences.containsKey(persistSessionKey);

@override
Future<String?> accessToken() async =>
_preferences.getString(persistSessionKey);

@override
Future<void> removePersistedSession() =>
_preferences.remove(persistSessionKey);

@override
Future<void> persistSession(String persistSessionString) =>
_preferences.setString(persistSessionKey, persistSessionString);
}

await Supabase.initialize(
url: url,
publishableKey: publishableKey,
authOptions: FlutterAuthClientOptions(
localStorage: LegacySharedPreferencesLocalStorage(
persistSessionKey: defaultPersistSessionKey(url),
),
),
);
```

Widget tests that call `Supabase.initialize` need one more line of setup.
`SharedPreferences.setMockInitialValues()` only stands in for the legacy API, so on its own the new
storage throws `StateError: The SharedPreferencesAsyncPlatform instance must be set.` Register an
in-memory async store next to it:

```dart
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';

setUp(() {
SharedPreferences.setMockInitialValues({});
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
```

`shared_preferences_platform_interface` needs to be a `dev_dependency` for that import. Passing
`FlutterAuthClientOptions(localStorage: const EmptyLocalStorage())` instead skips storage in tests
altogether.

### `supabasePersistSessionKey` is gone

The constant existed for the v1 to v2 migration from Hive, which v3 no longer carries, and the SDK
itself never read it. The session is stored under the key you pass to `LocalStorage`, which for the
default storage is `sb-<project-ref>-auth-token`.

The `LocalStorage` examples in the README used the constant as their storage key, so if you copied
one of those, take the key as a parameter instead:

```dart
// Before
class MySecureStorage extends LocalStorage {
@override
Future<String?> accessToken() => storage.read(key: supabasePersistSessionKey);
// ...
}

// After
class MySecureStorage extends LocalStorage {
MySecureStorage({required this.persistSessionKey});

final String persistSessionKey;

@override
Future<String?> accessToken() => storage.read(key: persistSessionKey);
// ...
}

await Supabase.initialize(
url: url,
publishableKey: publishableKey,
authOptions: FlutterAuthClientOptions(
localStorage: MySecureStorage(
persistSessionKey: defaultPersistSessionKey(url),
),
),
);
```

Passing the key you already store under keeps your users signed in; switching to a different key
signs them out once. To keep the old value, pass `'SUPABASE_PERSIST_SESSION_KEY'`, which is what the
constant held.

The `MigrationLocalStorage` and `HiveLocalStorage` snippets that migrated a v1 session out of
[hive](https://pub.dev/packages/hive) are gone from the README along with it. If you are still on
v1, upgrade to v2 first and let it migrate the session, then move to v3.
3 changes: 1 addition & 2 deletions packages/gotrue/lib/src/gotrue_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1512,8 +1512,7 @@ class GoTrueClient {
void _mayStartBroadcastChannel() {
if (const bool.fromEnvironment('dart.library.js_interop')) {
// Used by the js library as well
final broadcastKey =
"sb-${Uri.parse(_url).host.split(".").first}-auth-token";
final broadcastKey = defaultPersistSessionKey(_url);

assert(
_broadcastChannel == null,
Expand Down
5 changes: 4 additions & 1 deletion packages/gotrue/test/admin_list_users_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ class ListUsersMockClient extends BaseClient {
utf8.encode(
jsonEncode({
'users': [
{'id': '2fa5b8b0-4f1a-4a5c-9d47-1cf3dbb9f7e1'},
{
'id': '2fa5b8b0-4f1a-4a5c-9d47-1cf3dbb9f7e1',
'created_at': '2024-01-01T00:00:00Z',
},
],
'aud': ?audience,
}),
Expand Down
11 changes: 11 additions & 0 deletions packages/supabase_common/lib/src/persist_session_key.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// The key the user session is persisted under for the project at
/// [supabaseUrl].
///
/// This is the key `Supabase.initialize` passes to the default `LocalStorage`,
/// so pass it to your own `LocalStorage` implementation to keep reading and
/// writing the session the SDK already persisted.
///
/// The other Supabase client libraries derive the key the same way, so a
/// session written by one of them is found by the others.
String defaultPersistSessionKey(String supabaseUrl) =>
'sb-${Uri.parse(supabaseUrl).host.split('.').first}-auth-token';
1 change: 1 addition & 0 deletions packages/supabase_common/lib/supabase_common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export 'src/client_info.dart';
export 'src/fetch_options.dart';
export 'src/http_method.dart';
export 'src/http_status.dart';
export 'src/persist_session_key.dart';
export 'src/pkce.dart';
export 'src/platform/platform_info.dart';
export 'src/replay_subject.dart';
Expand Down
27 changes: 27 additions & 0 deletions packages/supabase_common/test/persist_session_key_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:supabase_common/supabase_common.dart';
import 'package:test/test.dart';

void main() {
group('defaultPersistSessionKey', () {
test('derives the key from the project ref', () {
expect(
defaultPersistSessionKey('https://abcdefghijklmnop.supabase.co'),
'sb-abcdefghijklmnop-auth-token',
);
});

test('ignores the port and the path', () {
expect(
defaultPersistSessionKey('http://localhost:54321/rest/v1'),
'sb-localhost-auth-token',
);
});

test('handles a custom domain', () {
expect(
defaultPersistSessionKey('https://auth.example.com'),
'sb-auth-auth-token',
);
});
});
}
Loading
Loading