Skip to content

Commit

Permalink
GS-10400 refresh token logic update (#2321)
Browse files Browse the repository at this point in the history
Co-authored-by: nickdpicnic <[email protected]>
  • Loading branch information
2 people authored and github-actions[bot] committed Oct 4, 2023
1 parent 863dbf3 commit 688c045
Show file tree
Hide file tree
Showing 11 changed files with 172 additions and 14 deletions.
125 changes: 117 additions & 8 deletions lib/core/data/graphql/graphql_auth_link.dart
Original file line number Diff line number Diff line change
@@ -1,29 +1,66 @@
import 'dart:async';

import 'package:graphql/client.dart';
import 'package:picnic_app/core/data/graphql/auth_queries.dart';
import 'package:picnic_app/core/data/graphql/graphql_logger.dart';
import 'package:picnic_app/core/data/graphql/model/gql_auth_result.dart';
import 'package:picnic_app/core/data/graphql/refresh_auth_token_link.dart';
import 'package:picnic_app/core/domain/repositories/auth_token_repository.dart';
import 'package:picnic_app/core/domain/repositories/token_decoder_repository.dart';
import 'package:picnic_app/core/domain/use_cases/get_auth_token_use_case.dart';
import 'package:picnic_app/core/domain/use_cases/save_auth_token_use_case.dart';
import 'package:picnic_app/core/environment_config/environment_config_provider.dart';
import 'package:picnic_app/core/utils/either_extensions.dart';
import 'package:picnic_app/core/utils/logging.dart';
import 'package:picnic_app/features/onboarding/domain/model/auth_token.dart';

class GraphQLAuthLink extends Link {
GraphQLAuthLink(
this._authTokenRepository,
this._tokenDecoderRepository,
this._saveAuthTokenUseCase,
this._getAuthTokenUseCase,
this._refreshTokenClient,
this._configProvider,
this._logger,
);

static Completer<QueryResult<GqlAuthResult>>? _refreshCompleter;
final AuthTokenRepository _authTokenRepository;
final TokenDecoderRepository _tokenDecoderRepository;
final SaveAuthTokenUseCase _saveAuthTokenUseCase;
final GetAuthTokenUseCase _getAuthTokenUseCase;
final GraphQLClient _refreshTokenClient;
final EnvironmentConfigProvider _configProvider;
final GraphQLLogger _logger;

@override
Stream<Response> request(Request request, [NextLink? forward]) async* {
var req = request;
final token = await _getToken();
if (token.isNotEmpty) {

final authInfo = await _authTokenRepository.getAuthToken().asyncFold(
(fail) => const AuthToken.empty(),
(success) => success,
);

var accessToken = authInfo.accessToken;
final refreshToken = authInfo.refreshToken;

if (accessToken.isNotEmpty &&
refreshToken.isNotEmpty &&
_tokenDecoderRepository.isExpired(accessToken) &&
!_tokenDecoderRepository.isExpired(refreshToken)) {
accessToken = await _refreshToken();
}
if (accessToken.isNotEmpty) {
req = request.updateContextEntry<HttpLinkHeaders>(
(HttpLinkHeaders? headers) {
return HttpLinkHeaders(
headers: <String, String>{
// put oldest headers
...headers?.headers ?? <String, String>{},
// and add a new headers
'Authorization': 'Bearer $token',
'Authorization': 'Bearer $accessToken',
},
);
},
Expand All @@ -33,10 +70,82 @@ class GraphQLAuthLink extends Link {
yield* forward!(req);
}

Future<String> _getToken() async {
return _authTokenRepository.getAuthToken().asyncFold(
(fail) => '',
(success) => success.accessToken,
);
Future<String> _refreshToken() async {
try {
final response = await _performRefresh();

return await _saveAuthTokenUseCase.execute(authToken: response.authToken).asyncFold(
(fail) {
return '';
},
(success) {
return response.authToken.accessToken;
},
);
} catch (ex) {
return '';
}
}

Future<QueryResult<GqlAuthResult>> _performRefresh() async {
var completer = _refreshCompleter;
if (completer?.isCompleted == false) {
debugLog("There is already 'refreshToken' request in progress, waiting until its finished", this);
//this makes sure we run only one refresh-token request at a time, so that if multiple concurrent requests fail,
// we don't run the refresh token
return completer!.future;
}
completer = Completer<QueryResult<GqlAuthResult>>();
_refreshCompleter = completer;
debugLog("Refreshing accessToken...", this);
final tokensResult = await _getAuthTokenUseCase.execute();
if (tokensResult.isFailure) {
logError("fail when getting tokens from storage: ${tokensResult.getFailure()}", logToCrashlytics: false);
completer.completeError(tokensResult.getFailure()!);
return completer.future;
}
final tokenResult = tokensResult.getSuccess()!;
if (tokenResult.accessToken.isEmpty) {
logError("missing accessToken, nothing to refresh", logToCrashlytics: false);
completer.completeError("Response is missing accessToken: $tokenResult");
return completer.future;
}
var response = await _refreshTokenRequest(tokenResult);
if (response.hasException) {
logError(response.exception, logToCrashlytics: false);
//yielding original's request exception, not the refresh token's one
completer.completeError(response.exception!);
return completer.future;
}
completer.complete(response);
return completer.future;
}

Future<QueryResult<GqlAuthResult>> _refreshTokenRequest(AuthToken tokens) async {
final variables = {
'accessToken': tokens.accessToken,
// for legacy purposes, users who don't have refresh token (the ones using the older version
// of the app that didn't support refresh tokens)
// can use their access token as refresh token and they'll receive proper refresh token in response
'refreshToken': tokens.refreshToken.isEmpty ? tokens.accessToken : tokens.refreshToken,
};
final doc = refreshTokenMutation(
includeDebugOption: await _configProvider.shouldUseShortLivedAuthTokens(),
);
final requestId = _logger.logRequest(doc: doc, vars: variables);
final response = await _refreshTokenClient.mutate(
MutationOptions(
document: gql(
doc,
),
parserFn: (json) {
return GqlAuthResult.fromJson((json['refreshTokens'] as Map).cast());
},
variables: variables,
),
);
response.isOptimistic;
_logger.logResponse(requestId: requestId, result: response);
return response;
}
}
13 changes: 12 additions & 1 deletion lib/core/data/graphql/graphql_client_factory.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:picnic_app/core/data/graphql/graphql_version_link.dart';
import 'package:picnic_app/core/data/graphql/refresh_auth_token_link.dart';
import 'package:picnic_app/core/domain/repositories/auth_token_repository.dart';
import 'package:picnic_app/core/domain/repositories/background_api_repository.dart';
import 'package:picnic_app/core/domain/repositories/token_decoder_repository.dart';
import 'package:picnic_app/core/domain/stores/app_info_store.dart';
import 'package:picnic_app/core/domain/use_cases/get_auth_token_use_case.dart';
import 'package:picnic_app/core/domain/use_cases/save_auth_token_use_case.dart';
Expand All @@ -21,6 +22,7 @@ class GraphqlClientFactory {
const GraphqlClientFactory(
this._configProvider,
this._authTokenRepository,
this._tokenDecoderRepository,
this._failureMapper,
this._saveAuthTokenUseCase,
this._getAuthTokenUseCase,
Expand All @@ -39,6 +41,7 @@ class GraphqlClientFactory {
//used for overriding in tests
final gql.Store? store;
final AuthTokenRepository _authTokenRepository;
final TokenDecoderRepository _tokenDecoderRepository;
final EnvironmentConfigProvider _configProvider;
final GraphQLFailureMapper _failureMapper;
final SaveAuthTokenUseCase _saveAuthTokenUseCase;
Expand Down Expand Up @@ -73,7 +76,15 @@ class GraphqlClientFactory {
_logger,
),
GraphQLHeadersLink(_configProvider),
GraphQLAuthLink(_authTokenRepository),
GraphQLAuthLink(
_authTokenRepository,
_tokenDecoderRepository,
_saveAuthTokenUseCase,
_getAuthTokenUseCase,
refreshTokenClient,
_configProvider,
_logger,
),
GraphQLVersionLink(
_appInfoStore,
_setAppInfoUseCase,
Expand Down
9 changes: 9 additions & 0 deletions lib/core/data/jwt_token_decoder_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:picnic_app/core/domain/repositories/token_decoder_repository.dart';

class JwtTokenDecoderRepository implements TokenDecoderRepository {
@override
bool isExpired(String token) {
return JwtDecoder.isExpired(token);
}
}
3 changes: 3 additions & 0 deletions lib/core/domain/repositories/token_decoder_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
abstract class TokenDecoderRepository {
bool isExpired(String token);
}
6 changes: 6 additions & 0 deletions lib/dependency_injection/app_component.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import 'package:picnic_app/core/data/hive/hive_path_provider.dart';
import 'package:picnic_app/core/data/hive_local_storage_repository.dart';
import 'package:picnic_app/core/data/impl_deep_links_repository.dart';
import 'package:picnic_app/core/data/impl_session_expired_repository.dart';
import 'package:picnic_app/core/data/jwt_token_decoder_repository.dart';
import 'package:picnic_app/core/data/local_user_preferences_repository.dart';
import 'package:picnic_app/core/data/mobile_feature_flags_repository.dart';
import 'package:picnic_app/core/data/native_recaptcha_repository.dart';
Expand Down Expand Up @@ -83,6 +84,7 @@ import 'package:picnic_app/core/domain/repositories/seeds_repository.dart';
import 'package:picnic_app/core/domain/repositories/session_expired_repository.dart';
import 'package:picnic_app/core/domain/repositories/slices_repository.dart';
import 'package:picnic_app/core/domain/repositories/social_accounts_repository.dart';
import 'package:picnic_app/core/domain/repositories/token_decoder_repository.dart';
import 'package:picnic_app/core/domain/repositories/user_preferences_repository.dart';
import 'package:picnic_app/core/domain/repositories/users_repository.dart';
import 'package:picnic_app/core/domain/stores/app_info_store.dart';
Expand Down Expand Up @@ -371,6 +373,7 @@ void _configureGeneralDependencies(
getIt(),
getIt(),
getIt(),
getIt(),
),
)
..registerLazySingleton<GraphQLClient>(
Expand Down Expand Up @@ -527,6 +530,9 @@ void _configureRepositories() {
getIt(),
),
)
..registerLazySingleton<TokenDecoderRepository>(
() => JwtTokenDecoderRepository(),
)
..registerFactory<PodsRepository>(
() => GraphqlPodsRepository(
getIt(),
Expand Down
8 changes: 8 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.0"
jwt_decoder:
dependency: "direct main"
description:
name: jwt_decoder
sha256: "54774aebf83f2923b99e6416b4ea915d47af3bde56884eb622de85feabbc559f"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
keyboard_actions:
dependency: "direct main"
description:
Expand Down
3 changes: 3 additions & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ dependencies:

chewie: 1.4.0

#jwt_decoder:
jwt_decoder: ^2.0.1

#dismissible_page
dismissible_page: 1.0.1

Expand Down
1 change: 1 addition & 0 deletions test/core/data/graphql_client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ class TestGraphQLIsolateDependenciesConfigurator implements GraphQLIsolateDepend
return GraphqlClientFactory(
getIt<EnvironmentConfigProvider>(),
getIt.get<AuthTokenRepository>(),
Mocks.tokenDecoderRepository,
getIt.get<GraphQLFailureMapper>(),
SaveAuthTokenUseCase(getIt.get<AuthTokenRepository>()),
getIt.get<GetAuthTokenUseCase>(),
Expand Down
12 changes: 7 additions & 5 deletions test/core/data/graphql_refresh_token_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ void main() {
test(
'403 from backend should trigger refreshTokens',
() async {
when(() => Mocks.tokenDecoderRepository.isExpired(expiredAccessToken)).thenReturn(true);
when(() => Mocks.tokenDecoderRepository.isExpired('refreshToken')).thenReturn(false);
final feedsQueryFuture = _feedsQuery(client);
_mockHttpResponses(
Mocks.dioClient,
Expand All @@ -48,14 +50,13 @@ void main() {
),
).captured;

expect((requestsList[1] as dio.Options).headers!['Authorization'], "Bearer expired token");
final secondRequestData = requestsList[2] as Map<String, dynamic>;
final query = secondRequestData['query'] as String;
final variables = secondRequestData['variables'] as Map<String, dynamic>;
final tokenRefreshRequestData = requestsList[0] as Map<String, dynamic>;
final query = tokenRefreshRequestData['query'] as String;
final variables = tokenRefreshRequestData['variables'] as Map<String, dynamic>;
expect(query.contains("mutation refreshTokens"), isNotNull);
expect(variables['refreshToken'], 'refreshToken');
expect(variables["accessToken"], expiredAccessToken);
expect((requestsList[5] as dio.Options).headers!['Authorization'], "Bearer valid token");
expect((requestsList[3] as dio.Options).headers!['Authorization'], "Bearer valid token");
expect(result.isSuccess, true);
},
);
Expand All @@ -76,6 +77,7 @@ void main() {
GraphqlClientFactory(
Mocks.environmentConfigProvider,
authTokenRepo,
Mocks.tokenDecoderRepository,
const GraphQLFailureMapper(),
SaveAuthTokenUseCase(authTokenRepo),
GetAuthTokenUseCase(authTokenRepo),
Expand Down
3 changes: 3 additions & 0 deletions test/mocks/mock_definitions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import 'package:picnic_app/core/domain/repositories/seeds_repository.dart';
import 'package:picnic_app/core/domain/repositories/session_expired_repository.dart';
import 'package:picnic_app/core/domain/repositories/slices_repository.dart';
import 'package:picnic_app/core/domain/repositories/social_accounts_repository.dart';
import 'package:picnic_app/core/domain/repositories/token_decoder_repository.dart';
import 'package:picnic_app/core/domain/repositories/user_preferences_repository.dart';
import 'package:picnic_app/core/domain/repositories/users_repository.dart';
import 'package:picnic_app/core/domain/stores/app_info_store.dart';
Expand Down Expand Up @@ -525,6 +526,8 @@ class MockCacheManagementRepository extends Mock implements CacheManagementRepos

class MockAuthTokenRepository extends Mock implements AuthTokenRepository {}

class MockTokenDecoderRepository extends Mock implements TokenDecoderRepository {}

class MockSocialAccountsRepository extends Mock implements SocialAccountsRepository {}
//DO-NOT-REMOVE REPOSITORIES_MOCK_DEFINITION

Expand Down
3 changes: 3 additions & 0 deletions test/mocks/mocks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ class Mocks {

static late MockAuthTokenRepository authTokenRepository;

static late MockTokenDecoderRepository tokenDecoderRepository;

static late MockPodsRepository podsRepository;
static late MockSocialAccountsRepository socialAccountsRepository;
//DO-NOT-REMOVE REPOSITORIES_MOCKS_STATIC_FIELD
Expand Down Expand Up @@ -611,6 +613,7 @@ class Mocks {
cacheManagementRepository = MockCacheManagementRepository();
postsRepository = MockPostsRepository();
authTokenRepository = MockAuthTokenRepository();
tokenDecoderRepository = MockTokenDecoderRepository();
podsRepository = MockPodsRepository();
socialAccountsRepository = MockSocialAccountsRepository();
//DO-NOT-REMOVE REPOSITORIES_INIT_MOCKS
Expand Down

0 comments on commit 688c045

Please sign in to comment.