Skip to content

feat: Add third-party auth support #999

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 12, 2024
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
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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a custom access token is passed, no auth instance should be created, right? From what I see, this is still the case, but just because it's marked as late. I would prefer a clearer structure to ensure no auth instance is created.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can always come back and change the implementation of it. For now, I'm going to merge this PR as this is not really critical.

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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe create a typedef for this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback! I know this is highly opinionated, but I really feel that using typedef generally decreases the readability of the code. Especially since a property named accessToken doesn't sound like a function.

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
Loading