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
25 changes: 25 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 31 additions & 3 deletions packages/supabase/lib/src/supabase_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand All @@ -110,7 +120,9 @@ class SupabaseClient {
supabaseUrl,
);
_authApiHttpClient = tracedHttpClient;
_authInstance = _initSupabaseAuthClient(authOptions);
if (accessToken == null) {
_authInstance = _initSupabaseAuthClient(authOptions);
}
_authHttpClient = AuthHttpClient(
_supabaseKey,
tracedHttpClient,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.';
}
52 changes: 49 additions & 3 deletions packages/supabase/test/client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AssertionError>().having(
isA<ArgumentError>().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<ArgumentError>().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<AuthException>()));
});

test('the pkce flow works with a MemoryAuthAsyncStorage', () async {
final supabase = real.SupabaseClient(
'http://localhost:1',
Expand Down
46 changes: 24 additions & 22 deletions packages/supabase_auth/lib/src/auth_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -679,7 +685,7 @@ class AuthClient {
);
}

final codeVerifierRawString = await _pkceVerifierStore!.retrieve(
final codeVerifierRawString = await pkceVerifierStore.retrieve(
flowId: requestedFlowId,
);
if (codeVerifierRawString == null) {
Expand All @@ -699,7 +705,7 @@ class AuthClient {
),
);

await _pkceVerifierStore.remove(flowId: requestedFlowId);
await pkceVerifierStore.remove(flowId: requestedFlowId);

final authSessionUrlResponse = AuthSessionUrlResponse(
session: Session.fromJson(response)!,
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 21 additions & 3 deletions packages/supabase_auth/test/client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AssertionError>().having(
isA<ArgumentError>().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<ArgumentError>().having(
(error) => error.message,
'message',
contains('persistSession needs an asyncStorage'),
),
),
);
Expand Down
Loading