Skip to content

Commit

Permalink
feat: Add third-party auth support (#999)
Browse files Browse the repository at this point in the history
* feat: add accessToken option

* delete empty line

* fix failing test

* properly return the access token in _getAccessToken

* make sure auth isn't called internally when accessToken is used

* add access token parameter to Supabase.initialize()
  • Loading branch information
dshukertjr authored Aug 12, 2024
1 parent 7da6856 commit c68d44d
Show file tree
Hide file tree
Showing 5 changed files with 80 additions and 35 deletions.
26 changes: 5 additions & 21 deletions packages/supabase/lib/src/auth_http_client.dart
Original file line number Diff line number Diff line change
@@ -1,32 +1,16 @@
import 'package:http/http.dart';
import 'package:supabase/supabase.dart';

class AuthHttpClient extends BaseClient {
final Client _inner;
final GoTrueClient _auth;
final String _supabaseKey;

AuthHttpClient(this._supabaseKey, this._inner, this._auth);
final String _supabaseKey;
final Future<String?> Function() _getAccessToken;
AuthHttpClient(this._supabaseKey, this._inner, this._getAccessToken);

@override
Future<StreamedResponse> send(BaseRequest request) async {
if (_auth.currentSession?.isExpired ?? false) {
try {
await _auth.refreshSession();
} catch (error) {
final expiresAt = _auth.currentSession?.expiresAt;
if (expiresAt != null) {
// Failed to refresh the token.
final isExpiredWithoutMargin = DateTime.now()
.isAfter(DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000));
if (isExpiredWithoutMargin) {
// Throw the error instead of making an API request with an expired token.
rethrow;
}
}
}
}
final authBearer = _auth.currentSession?.accessToken ?? _supabaseKey;
final accessToken = await _getAccessToken();
final authBearer = accessToken ?? _supabaseKey;

request.headers.putIfAbsent("Authorization", () => 'Bearer $authBearer');
request.headers.putIfAbsent("apikey", () => _supabaseKey);
Expand Down
73 changes: 60 additions & 13 deletions packages/supabase/lib/src/supabase_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ import 'counter.dart';
///
/// [realtimeClientOptions] specifies different options you can pass to `RealtimeClient`.
///
/// [accessToken] Optional function for using a third-party authentication system with Supabase.
/// The function should return an access token or ID token (JWT) by obtaining
/// it from the third-party auth client library. Note that this function may be
/// called concurrently and many times. Use memoization and locking techniques
/// if this is not supported by the client libraries. When set, the `auth`
/// namespace of the Supabase client cannot be used.
///
/// Pass an instance of `YAJsonIsolate` to [isolate] to use your own persisted
/// isolate instance. A new instance will be created if [isolate] is omitted.
///
Expand All @@ -43,7 +50,7 @@ class SupabaseClient {
final Client? _httpClient;
late final Client _authHttpClient;

late final GoTrueClient auth;
late final GoTrueClient _authInstance;

/// Supabase Functions allows you to deploy and invoke edge functions.
late final FunctionsClient functions;
Expand All @@ -52,8 +59,9 @@ class SupabaseClient {
late final SupabaseStorageClient storage;
late final RealtimeClient realtime;
late final PostgrestClient rest;
late StreamSubscription<AuthState> _authStateSubscription;
StreamSubscription<AuthState>? _authStateSubscription;
late final YAJsonIsolate _isolate;
final Future<String> Function()? accessToken;

/// Increment ID of the stream to create different realtime topic for each stream
final _incrementId = Counter();
Expand Down Expand Up @@ -83,13 +91,15 @@ class SupabaseClient {
..clear()
..addAll(_headers);

auth.headers
..clear()
..addAll({
...Constants.defaultHeaders,
..._getAuthHeaders(),
...headers,
});
if (accessToken == null) {
auth.headers
..clear()
..addAll({
...Constants.defaultHeaders,
..._getAuthHeaders(),
...headers,
});
}

// To apply the new headers in the realtime client,
// manually unsubscribe and resubscribe to all channels.
Expand All @@ -106,6 +116,7 @@ class SupabaseClient {
AuthClientOptions authOptions = const AuthClientOptions(),
StorageClientOptions storageOptions = const StorageClientOptions(),
RealtimeClientOptions realtimeClientOptions = const RealtimeClientOptions(),
this.accessToken,
Map<String, String>? headers,
Client? httpClient,
YAJsonIsolate? isolate,
Expand All @@ -122,18 +133,30 @@ class SupabaseClient {
},
_httpClient = httpClient,
_isolate = isolate ?? (YAJsonIsolate()..initialize()) {
auth = _initSupabaseAuthClient(
_authInstance = _initSupabaseAuthClient(
autoRefreshToken: authOptions.autoRefreshToken,
gotrueAsyncStorage: authOptions.pkceAsyncStorage,
authFlowType: authOptions.authFlowType,
);
_authHttpClient =
AuthHttpClient(_supabaseKey, httpClient ?? Client(), auth);
AuthHttpClient(_supabaseKey, httpClient ?? Client(), _getAccessToken);
rest = _initRestClient();
functions = _initFunctionsClient();
storage = _initStorageClient(storageOptions.retryAttempts);
realtime = _initRealtimeClient(options: realtimeClientOptions);
_listenForAuthEvents();
if (accessToken == null) {
_listenForAuthEvents();
}
}

GoTrueClient get auth {
if (accessToken == null) {
return _authInstance;
} else {
throw AuthException(
'Supabase Client is configured with the accessToken option, accessing supabase.auth is not possible.',
);
}
}

/// Perform a table operation.
Expand Down Expand Up @@ -200,8 +223,32 @@ class SupabaseClient {
return realtime.removeAllChannels();
}

Future<String?> _getAccessToken() async {
if (accessToken != null) {
return await accessToken!();
}

if (_authInstance.currentSession?.isExpired ?? false) {
try {
await _authInstance.refreshSession();
} catch (error) {
final expiresAt = _authInstance.currentSession?.expiresAt;
if (expiresAt != null) {
// Failed to refresh the token.
final isExpiredWithoutMargin = DateTime.now()
.isAfter(DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000));
if (isExpiredWithoutMargin) {
// Throw the error instead of making an API request with an expired token.
rethrow;
}
}
}
}
return _authInstance.currentSession?.accessToken;
}

Future<void> dispose() async {
await _authStateSubscription.cancel();
await _authStateSubscription?.cancel();
await _isolate.dispose();
}

Expand Down
10 changes: 10 additions & 0 deletions packages/supabase/test/client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,16 @@ void main() {

mockServer.close();
});

test('create a client with third-party auth accessToken', () async {
final supabase = SupabaseClient('URL', 'KEY', accessToken: () async {
return 'jwt';
});
expect(
() => supabase.auth.currentUser,
throwsA(AuthException(
'Supabase Client is configured with the accessToken option, accessing supabase.auth is not possible.')));
});
});

group('Custom Header', () {
Expand Down
4 changes: 4 additions & 0 deletions packages/supabase_flutter/lib/src/supabase.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class Supabase {
PostgrestClientOptions postgrestOptions = const PostgrestClientOptions(),
StorageClientOptions storageOptions = const StorageClientOptions(),
FlutterAuthClientOptions authOptions = const FlutterAuthClientOptions(),
Future<String> Function()? accessToken,
bool? debug,
}) async {
assert(
Expand Down Expand Up @@ -103,6 +104,7 @@ class Supabase {
authOptions: authOptions,
postgrestOptions: postgrestOptions,
storageOptions: storageOptions,
accessToken: accessToken,
);
_instance._debugEnable = debug ?? kDebugMode;
_instance.log('***** Supabase init completed $_instance');
Expand Down Expand Up @@ -154,6 +156,7 @@ class Supabase {
required PostgrestClientOptions postgrestOptions,
required StorageClientOptions storageOptions,
required AuthClientOptions authOptions,
required Future<String> Function()? accessToken,
}) {
final headers = {
...Constants.defaultHeaders,
Expand All @@ -168,6 +171,7 @@ class Supabase {
postgrestOptions: postgrestOptions,
storageOptions: storageOptions,
authOptions: authOptions,
accessToken: accessToken,
);
_initialized = true;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/supabase_flutter/test/supabase_flutter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ void main() {
/// Check if the current version of AppLinks uses an explicit call to get
/// the initial link. This is only the case before version 6.0.0, where we
/// can find the getInitialAppLink function.
///
///
/// CI pipeline is set so that it tests both app_links newer and older than v6.0.0
bool appLinksExposesInitialLinkInStream() {
try {
Expand Down

0 comments on commit c68d44d

Please sign in to comment.