Skip to content

Commit

Permalink
feat(supabase_flutter): use SharedPreferences for access token (#608)
Browse files Browse the repository at this point in the history
* feat: use SharedPreferences for access token

* chore: add path as dev dependency

* fix: use MigrationLocalStorage by default

* feat: match flutter and js web storage for access token

* fix: use same storage key as supabase-js

* fix: remove persistSessionString

* test: fix tests

* test: fix tests

* test: fix test
  • Loading branch information
Vinzent03 committed Sep 5, 2023
1 parent 0c6caa0 commit 9d72a59
Show file tree
Hide file tree
Showing 15 changed files with 330 additions and 137 deletions.
21 changes: 3 additions & 18 deletions packages/gotrue/lib/src/gotrue_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -773,29 +773,14 @@ class GoTrueClient {
);
}

/// Recover session from persisted session json string.
/// Persisted session json has the format { currentSession, expiresAt }
///
/// currentSession: session json object, expiresAt: timestamp in seconds
/// Recover session from stringified [Session].
Future<AuthResponse> recoverSession(String jsonStr) async {
final persistedData = json.decode(jsonStr) as Map<String, dynamic>;
final currentSession =
persistedData['currentSession'] as Map<String, dynamic>?;
final expiresAt = persistedData['expiresAt'] as int?;
if (currentSession == null) {
throw _notifyException(AuthException('Missing currentSession.'));
}
if (expiresAt == null) {
throw _notifyException(AuthException('Missing expiresAt.'));
}

final session = Session.fromJson(currentSession);
final session = Session.fromJson(json.decode(jsonStr));
if (session == null) {
throw _notifyException(AuthException('Current session is missing data.'));
}

final timeNow = (DateTime.now().millisecondsSinceEpoch / 1000).round();
if (expiresAt < (timeNow + Constants.expiryMargin.inSeconds)) {
if (session.isExpired) {
if (_autoRefreshToken && session.refreshToken != null) {
return await _callRefreshToken(
refreshToken: session.refreshToken,
Expand Down
15 changes: 5 additions & 10 deletions packages/gotrue/lib/src/types/session.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import 'dart:convert';

import 'package:gotrue/src/constants.dart';
import 'package:gotrue/src/types/user.dart';
import 'package:jwt_decode/jwt_decode.dart';

Expand Down Expand Up @@ -47,6 +46,7 @@ class Session {
return {
'access_token': accessToken,
'expires_in': expiresIn,
'expires_at': expiresAt,
'refresh_token': refreshToken,
'token_type': tokenType,
'provider_token': providerToken,
Expand All @@ -68,21 +68,16 @@ class Session {
}
}

/// Returns 'true` if the token is expired or will expire in the next 5 seconds.
/// Returns 'true` if the token is expired or will expire in the next 10 seconds.
///
/// The 5 second buffer is to account for latency issues.
/// The 10 second buffer is to account for latency issues.
bool get isExpired {
if (expiresAt == null) return false;
return DateTime.now().add(Duration(seconds: 5)).isAfter(
return DateTime.now().add(Constants.expiryMargin).isAfter(
DateTime.fromMillisecondsSinceEpoch(expiresAt! * 1000),
);
}

String get persistSessionString {
final data = {'currentSession': toJson(), 'expiresAt': expiresAt};
return json.encode(data);
}

Session copyWith({
String? accessToken,
int? expiresIn,
Expand Down
22 changes: 9 additions & 13 deletions packages/gotrue/test/client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,7 @@ void main() {
expect(data?.user.id, isA<String>());

final payload = Jwt.parseJwt(data!.accessToken);
final persistSession = json.decode(data.persistSessionString);
expect(payload['exp'], persistSession['expiresAt']);
expect(payload['exp'], data.expiresAt);
});

test('Get user', () async {
Expand All @@ -207,7 +206,7 @@ void main() {
expect(user.appMetadata['provider'], 'email');
});

test('signInWithPassword() with phone', () async {
test('signInWithPassword() with phone', () async {
final response =
await client.signInWithPassword(phone: phone1, password: password);
final data = response.session;
Expand All @@ -217,8 +216,7 @@ void main() {
expect(data?.user.id, isA<String>());

final payload = Jwt.parseJwt(data!.accessToken);
final persistSession = json.decode(data.persistSessionString);
expect(payload['exp'], persistSession['expiresAt']);
expect(payload['exp'], data.expiresAt);
});

test('Set session', () async {
Expand Down Expand Up @@ -337,7 +335,7 @@ void main() {
test('Repeatedly recover session', () async {
await client.signInWithPassword(password: password, email: email1);
for (int i = 0; i < 10; i++) {
final json = client.currentSession!.persistSessionString;
final json = jsonEncode(client.currentSession!);
await client.recoverSession(json);
}
});
Expand All @@ -357,13 +355,11 @@ void main() {
]),
);

final currentSession = client.currentSession!.toJson()
..['refresh_token'] = 'wrong';
final data = {'currentSession': currentSession, 'expiresAt': 100};
final session = json.encode(data);
final session =
getSessionData(DateTime.now().subtract(Duration(hours: 1)));

await expectLater(
client.recoverSession(session), throwsA(isA<AuthException>()));
await expectLater(client.recoverSession(session.sessionString),
throwsA(isA<AuthException>()));
expect(stream, emitsError(isA<AuthException>()));

expect(client.currentSession, isNull);
Expand Down Expand Up @@ -404,7 +400,7 @@ void main() {

test('Session recovery succeeds after retries', () async {
await client.recoverSession(
'{"currentSession":{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODAzNDE3MDUsInN1YiI6IjRkMjU4M2RhLThkZTQtNDlkMy05Y2QxLTM3YTlhNzRmNTViZCIsImVtYWlsIjoiZmFrZTE2ODAzMzgxMDVAZW1haWwuY29tIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6eyJIZWxsbyI6IldvcmxkIn0sInJvbGUiOiIiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTY4MDMzODEwNX1dLCJzZXNzaW9uX2lkIjoiYzhiOTg2Y2UtZWJkZC00ZGUxLWI4MjAtZjIyOWYyNjg1OGIwIn0.0x1rFlPKbIU1rZPY1SH_FNSZaXerfkFA1Y-EOlhuzUs","expires_in":3600,"refresh_token":"-yeS4omysFs9tpUYBws9Rg","token_type":"bearer","provider_token":null,"provider_refresh_token":null,"user":{"id":"4d2583da-8de4-49d3-9cd1-37a9a74f55bd","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"Hello":"World"},"aud":"","email":"fake1680338105@email.com","phone":"","created_at":"2023-04-01T08:35:05.208586Z","confirmed_at":null,"email_confirmed_at":"2023-04-01T08:35:05.220096086Z","phone_confirmed_at":null,"last_sign_in_at":"2023-04-01T08:35:05.222755878Z","role":"","updated_at":"2023-04-01T08:35:05.226938Z"}},"expiresAt":1680341705}');
'{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODAzNDE3MDUsInN1YiI6IjRkMjU4M2RhLThkZTQtNDlkMy05Y2QxLTM3YTlhNzRmNTViZCIsImVtYWlsIjoiZmFrZTE2ODAzMzgxMDVAZW1haWwuY29tIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6eyJIZWxsbyI6IldvcmxkIn0sInJvbGUiOiIiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTY4MDMzODEwNX1dLCJzZXNzaW9uX2lkIjoiYzhiOTg2Y2UtZWJkZC00ZGUxLWI4MjAtZjIyOWYyNjg1OGIwIn0.0x1rFlPKbIU1rZPY1SH_FNSZaXerfkFA1Y-EOlhuzUs","expires_in":3600,"refresh_token":"-yeS4omysFs9tpUYBws9Rg","token_type":"bearer","provider_token":null,"provider_refresh_token":null,"user":{"id":"4d2583da-8de4-49d3-9cd1-37a9a74f55bd","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"Hello":"World"},"aud":"","email":"fake1680338105@email.com","phone":"","created_at":"2023-04-01T08:35:05.208586Z","confirmed_at":null,"email_confirmed_at":"2023-04-01T08:35:05.220096086Z","phone_confirmed_at":null,"last_sign_in_at":"2023-04-01T08:35:05.222755878Z","role":"","updated_at":"2023-04-01T08:35:05.226938Z"},"expiresAt":1680341705}');
expect(httpClient.retryCount, 3);
});
});
Expand Down
13 changes: 13 additions & 0 deletions packages/gotrue/test/utils.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:dotenv/dotenv.dart';
import 'package:gotrue/gotrue.dart';
Expand Down Expand Up @@ -48,6 +50,17 @@ String getServiceRoleToken(DotEnv env) {
);
}

/// Construct session data for a given expiration date
({String accessToken, String sessionString}) getSessionData(DateTime dateTime) {
final expiresAt = dateTime.millisecondsSinceEpoch ~/ 1000;
final accessTokenMid = base64.encode(utf8.encode(json.encode(
{'exp': expiresAt, 'sub': '1234567890', 'role': 'authenticated'})));
final accessToken = 'any.$accessTokenMid.any';
final sessionString =
'{"access_token":"$accessToken","expires_in":${dateTime.difference(DateTime.now()).inSeconds},"refresh_token":"-yeS4omysFs9tpUYBws9Rg","token_type":"bearer","provider_token":null,"provider_refresh_token":null,"user":{"id":"4d2583da-8de4-49d3-9cd1-37a9a74f55bd","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"Hello":"World"},"aud":"","email":"fake1680338105@email.com","phone":"","created_at":"2023-04-01T08:35:05.208586Z","confirmed_at":null,"email_confirmed_at":"2023-04-01T08:35:05.220096086Z","phone_confirmed_at":null,"last_sign_in_at":"2023-04-01T08:35:05.222755878Z","role":"","updated_at":"2023-04-01T08:35:05.226938Z"}}';
return (accessToken: accessToken, sessionString: sessionString);
}

class TestAsyncStorage extends GotrueAsyncStorage {
final Map<String, String> _map = {};
@override
Expand Down
19 changes: 6 additions & 13 deletions packages/supabase/test/client_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';

import 'package:supabase/supabase.dart';
Expand Down Expand Up @@ -63,13 +62,8 @@ void main() {

group('auth', () {
test('properly set Authorization header', () async {
final expiresAt =
DateTime.now().add(Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000;
final accessToken = base64.encode(utf8.encode(json.encode(
{"exp": expiresAt, "sub": "1234567890", "role": "authenticated"})));

final sessionString =
'{"currentSession":{"access_token":"$accessToken","expires_in":3600,"refresh_token":"-yeS4omysFs9tpUYBws9Rg","token_type":"bearer","provider_token":null,"provider_refresh_token":null,"user":{"id":"4d2583da-8de4-49d3-9cd1-37a9a74f55bd","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"Hello":"World"},"aud":"","email":"fake1680338105@email.com","phone":"","created_at":"2023-04-01T08:35:05.208586Z","confirmed_at":null,"email_confirmed_at":"2023-04-01T08:35:05.220096086Z","phone_confirmed_at":null,"last_sign_in_at":"2023-04-01T08:35:05.222755878Z","role":"","updated_at":"2023-04-01T08:35:05.226938Z"}},"expiresAt":$expiresAt}';
final (:sessionString, :accessToken) =
getSessionData(DateTime.now().add(Duration(hours: 1)));

final mockServer = await HttpServer.bind('localhost', 0);
final client = SupabaseClient(
Expand Down Expand Up @@ -110,7 +104,7 @@ void main() {
autoRefreshToken: false,
);
final sessionData = getSessionData(expiresAt);
await client.auth.recoverSession(sessionData[2]);
await client.auth.recoverSession(sessionData.sessionString);

await Future.delayed(Duration(seconds: 11));

Expand All @@ -131,15 +125,14 @@ void main() {
fail("Token was refreshed twice");
}
gotTokenRefresh = true;
final sessionData =
String sessionString;
(accessToken: secondAccessToken, :sessionString) =
getSessionData(DateTime.now().add(Duration(hours: 1)));
secondAccessToken = sessionData[0];

final another = sessionData[1];
req.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write(another)
..write(sessionString)
..close();
} else {
expect(req.headers.value('Authorization')?.split(" ").last,
Expand Down
8 changes: 3 additions & 5 deletions packages/supabase/test/utils.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import 'dart:convert';

/// Construct session data for a given expiration date
List<String> getSessionData(DateTime dateTime) {
({String accessToken, String sessionString}) getSessionData(DateTime dateTime) {
final expiresAt = dateTime.millisecondsSinceEpoch ~/ 1000;
final accessTokenMid = base64.encode(utf8.encode(json.encode(
{"exp": expiresAt, "sub": "1234567890", "role": "authenticated"})));
final accessToken = "any.$accessTokenMid.any";
final currentSession =
'{"access_token":"$accessToken","expires_in":${dateTime.difference(DateTime.now()).inSeconds},"refresh_token":"-yeS4omysFs9tpUYBws9Rg","token_type":"bearer","provider_token":null,"provider_refresh_token":null,"user":{"id":"4d2583da-8de4-49d3-9cd1-37a9a74f55bd","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"Hello":"World"},"aud":"","email":"fake1680338105@email.com","phone":"","created_at":"2023-04-01T08:35:05.208586Z","confirmed_at":null,"email_confirmed_at":"2023-04-01T08:35:05.220096086Z","phone_confirmed_at":null,"last_sign_in_at":"2023-04-01T08:35:05.222755878Z","role":"","updated_at":"2023-04-01T08:35:05.226938Z"}}';
final sessionString =
'{"currentSession":$currentSession,"expiresAt":$expiresAt}';
return [accessToken, currentSession, sessionString];
'{"access_token":"$accessToken","expires_in":${dateTime.difference(DateTime.now()).inSeconds},"refresh_token":"-yeS4omysFs9tpUYBws9Rg","token_type":"bearer","provider_token":null,"provider_refresh_token":null,"user":{"id":"4d2583da-8de4-49d3-9cd1-37a9a74f55bd","app_metadata":{"provider":"email","providers":["email"]},"user_metadata":{"Hello":"World"},"aud":"","email":"fake1680338105@email.com","phone":"","created_at":"2023-04-01T08:35:05.208586Z","confirmed_at":null,"email_confirmed_at":"2023-04-01T08:35:05.220096086Z","phone_confirmed_at":null,"last_sign_in_at":"2023-04-01T08:35:05.222755878Z","role":"","updated_at":"2023-04-01T08:35:05.226938Z"}}';
return (accessToken: accessToken, sessionString: sessionString);
}
Loading

0 comments on commit 9d72a59

Please sign in to comment.