Skip to content

Commit

Permalink
feat(gotrue, supabase_flutter): New auth token refresh algorithm (#879)
Browse files Browse the repository at this point in the history
* align _callRefreshSession

* complete fetch rework

* Update the methods on gotrue-dart

* update the app lifecycle change listener

* Complete ticking behavior except tests

* minor comment update

* update the behavior when the SDK failed to refresh the token when making request

* adjust gotrue tests

* fix supabase test

* fix supabase_flutter tests

* Fix supabase-flutter test

* stop autorefresh timer within widget test

* Update packages/gotrue/lib/src/constants.dart

Co-authored-by: Vinzent <vinzent03@proton.me>

* Update packages/gotrue/lib/src/gotrue_client.dart

Co-authored-by: Vinzent <vinzent03@proton.me>

* Update packages/gotrue/lib/src/gotrue_client.dart

Co-authored-by: Vinzent <vinzent03@proton.me>

* Update packages/gotrue/lib/src/gotrue_client.dart

Co-authored-by: Vinzent <vinzent03@proton.me>

* Update packages/gotrue/lib/src/gotrue_client.dart

* Update packages/gotrue/lib/src/gotrue_client.dart

* minor refactor within _autoRefreshTokenTick

* rename errors to exceptions

* style: remove redundant bracket

* Have supabase_flutter respect the autoRefreshToken option for refreshing session

* remove outdated comment on _callRefreshToken

* Add error to onAuthStateChange stream for call refresh token

* startAutoRefresh within gotrue

---------

Co-authored-by: Vinzent <vinzent03@proton.me>
  • Loading branch information
dshukertjr and Vinzent03 committed Apr 9, 2024
1 parent 54a0b97 commit 9993168
Show file tree
Hide file tree
Showing 13 changed files with 355 additions and 235 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/supabase_flutter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ jobs:
with:
java-version: '12.x'

- uses: subosito/flutter-action@v1
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ matrix.flutter-version }}
channel: 'stable'

- run: flutter --version

- name: Bootstrap workspace
run: |
cd ../../
Expand All @@ -64,6 +66,7 @@ jobs:
run: dart format lib test -l 80 --set-exit-if-changed

- name: analyzer
if: ${{ matrix.sdk == '3.x'}}
run: flutter analyze --fatal-warnings --fatal-infos .

- name: Run tests
Expand Down
12 changes: 9 additions & 3 deletions packages/gotrue/lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ class Constants {

/// storage key prefix to store code verifiers
static const String defaultStorageKey = 'supabase.auth.token';
static const expiryMargin = Duration(seconds: 10);
static const int maxRetryCount = 10;
static const retryInterval = Duration(milliseconds: 200);

/// The margin to use when checking if a token is expired.
static const expiryMargin = Duration(seconds: 30);

/// Current session will be checked for refresh at this interval.
static const autoRefreshTickDuration = Duration(seconds: 10);

/// A token refresh will be attempted this many ticks before the current session expires.
static const autoRefreshTickThreshold = 3;
}

enum AuthChangeEvent {
Expand Down
154 changes: 103 additions & 51 deletions packages/gotrue/lib/src/fetch.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import 'dart:convert';

import 'package:collection/collection.dart';
import 'package:gotrue/src/types/auth_exception.dart';
import 'package:gotrue/src/types/fetch_options.dart';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';

enum RequestMethodType { get, post, put, delete }
Expand All @@ -16,23 +16,56 @@ class GotrueFetch {
return code >= 200 && code <= 299;
}

AuthException _handleError(http.Response error) {
late AuthException errorRes;
String _getErrorMessage(dynamic error) {
if (error is Map) {
return error['msg'] ??
error['message'] ??
error['error_description'] ??
error['error']?.toString() ??
error.toString();
}

return error.toString();
}

AuthException _handleError(dynamic error) {
if (error is! Response) {
throw AuthRetryableFetchException();
}

// If the status is 500 or above, it's likely a server error,
// and can be retried.
if (error.statusCode >= 500) {
throw AuthRetryableFetchException();
}

final dynamic data;
try {
final parsedJson = json.decode(error.body) as Map<String, dynamic>;
final String message = (parsedJson['msg'] ??
parsedJson['message'] ??
parsedJson['error_description'] ??
parsedJson['error'] ??
error.body)
.toString();
errorRes = AuthException(message, statusCode: '${error.statusCode}');
} catch (_) {
errorRes = AuthException(error.body, statusCode: '${error.statusCode}');
data = jsonDecode(error.body);
} catch (error) {
throw AuthUnknownException(
message: error.toString(), originalError: error);
}

// Check if weak password reasons only contain strings
if (data is Map &&
data['weak_password'] is Map &&
data['weak_password']['reasons'] is List &&
(data['weak_password']['reasons'] as List).isNotEmpty &&
(data['weak_password']['reasons'] as List)
.whereNot((element) => element is String)
.isEmpty) {
throw AuthWeakPasswordException(
message: _getErrorMessage(data),
statusCode: error.statusCode.toString(),
reasons: data['weak_password']['reasons'],
);
}

return errorRes;
throw AuthApiException(
_getErrorMessage(data),
statusCode: error.statusCode.toString(),
);
}

Future<dynamic> request(
Expand All @@ -51,52 +84,71 @@ class GotrueFetch {
}
Uri uri = Uri.parse(url);
uri = uri.replace(queryParameters: {...uri.queryParameters, ...qs});
late final http.Response response;

return await _handleRequest(
method: method, uri: uri, options: options, headers: headers);
}

Future<dynamic> _handleRequest({
required RequestMethodType method,
required Uri uri,
required GotrueRequestOptions? options,
required Map<String, String> headers,
}) async {
final bodyStr = json.encode(options?.body ?? {});

if (method != RequestMethodType.get) {
headers['Content-Type'] = 'application/json';
}
switch (method) {
case RequestMethodType.get:
response = await (httpClient?.get ?? http.get)(
uri,
headers: headers,
);

break;
case RequestMethodType.post:
response = await (httpClient?.post ?? http.post)(
uri,
headers: headers,
body: bodyStr,
);
break;
case RequestMethodType.put:
response = await (httpClient?.put ?? http.put)(
uri,
headers: headers,
body: bodyStr,
);
break;
case RequestMethodType.delete:
response = await (httpClient?.delete ?? http.delete)(
uri,
headers: headers,
body: bodyStr,
);
break;
}
Response response;
try {
switch (method) {
case RequestMethodType.get:
response = await (httpClient?.get ?? get)(
uri,
headers: headers,
);

if (isSuccessStatusCode(response.statusCode)) {
if (options?.noResolveJson == true) {
return response.body;
} else {
return json.decode(utf8.decode(response.bodyBytes));
break;
case RequestMethodType.post:
response = await (httpClient?.post ?? post)(
uri,
headers: headers,
body: bodyStr,
);
break;
case RequestMethodType.put:
response = await (httpClient?.put ?? put)(
uri,
headers: headers,
body: bodyStr,
);
break;
case RequestMethodType.delete:
response = await (httpClient?.delete ?? delete)(
uri,
headers: headers,
body: bodyStr,
);
break;
}
} else {
} catch (e) {
// fetch failed, likely due to a network or CORS error
throw AuthRetryableFetchException();
}

if (!isSuccessStatusCode(response.statusCode)) {
throw _handleError(response);
}

if (options?.noResolveJson == true) {
return response.body;
}

try {
return json.decode(utf8.decode(response.bodyBytes));
} catch (error) {
throw _handleError(error);
}
}
}
Loading

0 comments on commit 9993168

Please sign in to comment.