diff --git a/waydowntown_app/lib/app.dart b/waydowntown_app/lib/app.dart index b1bc9a83..6d10a595 100644 --- a/waydowntown_app/lib/app.dart +++ b/waydowntown_app/lib/app.dart @@ -6,6 +6,7 @@ import 'package:geolocator/geolocator.dart'; import 'package:talker_dio_logger/talker_dio_logger.dart'; import 'package:talker_flutter/talker_flutter.dart'; import 'package:waydowntown/developer_tools.dart'; +import 'package:waydowntown/refresh_token_interceptor.dart'; import 'package:waydowntown/routes/request_run_route.dart'; import 'package:waydowntown/widgets/session_widget.dart'; @@ -57,6 +58,7 @@ class _HomeState extends State { }, )); + dio.interceptors.add(RefreshTokenInterceptor(dio: dio)); dio.interceptors.add(TalkerDioLogger( talker: talker, settings: const TalkerDioLoggerSettings( diff --git a/waydowntown_app/lib/refresh_token_interceptor.dart b/waydowntown_app/lib/refresh_token_interceptor.dart new file mode 100644 index 00000000..5e6e2b88 --- /dev/null +++ b/waydowntown_app/lib/refresh_token_interceptor.dart @@ -0,0 +1,144 @@ +// Adapted from https://medium.com/@dariovarrialeapps/how-to-create-a-refresh-token-interceptor-in-flutter-with-dio-64a3ab0be6fa + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class RefreshTokenInterceptor extends InterceptorsWrapper { + final Dio dio; + final Dio? renewalDio; + final Dio? postRenewalDio; + + RefreshTokenInterceptor({ + required this.dio, + this.renewalDio, + this.postRenewalDio, + }); + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + _addTokenIfNeeded(options, handler); + } + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + _debugPrint('### Error: ${err.response?.statusCode} ###'); + if (err.response?.statusCode != 401) { + return handler.next(err); + } + + _refreshTokenAndResolveError(err, handler); + } + + /// Adds the user token to the request headers if it's not already there. + /// If the token is not present, the request will be sent without it. + /// + /// If the token is present, it will be added to the headers. + void _addTokenIfNeeded( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + if (options.headers.containsKey('Authorization')) { + return handler.next(options); + } + + final prefs = await SharedPreferences.getInstance(); + final userToken = prefs.getString('access_token'); + + if (userToken != null && userToken.isNotEmpty) { + options.headers['Authorization'] = userToken; + } + + handler.next(options); + } + + /// Refreshes the user token and retries the request. + /// If the token refresh fails, the error will be passed to the next interceptor. + void _refreshTokenAndResolveError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + _debugPrint('### Refreshing token... ###'); + final prefs = await SharedPreferences.getInstance(); + final refreshToken = prefs.getString('renewal_token'); + + if (refreshToken == null) { + return handler.next(err); + } + + late final Response authResponse; + + Dio actualRenewalDio; + + if (renewalDio == null) { + actualRenewalDio = Dio(); + actualRenewalDio.options = dio.options; + print('renewal dio options: ${actualRenewalDio.options.baseUrl}'); + } else { + print("using provided renewal dio"); + actualRenewalDio = renewalDio!; + } + + print("headers"); + print({ + 'Authorization': refreshToken, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }); + + try { + authResponse = await actualRenewalDio.post( + '/powapi/session/renew', + options: Options(headers: { + 'Authorization': refreshToken, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }), + ); + } catch (e) { + prefs.remove('access_token'); + prefs.remove('renewal_token'); + + if (e is DioException) { + return handler.next(e); + } + + return handler.next(err); + } + + _debugPrint('### Token refreshed! ###'); + + await prefs.setString( + 'access_token', authResponse.data['data']['access_token']); + await prefs.setString( + 'renewal_token', authResponse.data['data']['renewal_token']); + + err.requestOptions.headers['Authorization'] = + '${authResponse.data['data']['access_token']}'; + + Dio actualPostRenewalDio; + + if (postRenewalDio == null) { + actualPostRenewalDio = Dio(); + actualPostRenewalDio.options = dio.options; + } else { + actualPostRenewalDio = postRenewalDio!; + } + + final refreshResponse = + await actualPostRenewalDio.fetch(err.requestOptions); + return handler.resolve(refreshResponse); + } + + void _debugPrint(String message) { + if (kDebugMode) { + print(message); + } + } +} diff --git a/waydowntown_app/lib/widgets/session_widget.dart b/waydowntown_app/lib/widgets/session_widget.dart index d9e0481c..e31ba8bc 100644 --- a/waydowntown_app/lib/widgets/session_widget.dart +++ b/waydowntown_app/lib/widgets/session_widget.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:waydowntown/app.dart'; import 'package:waydowntown/tools/auth_form.dart'; import 'package:waydowntown/tools/my_specifications_table.dart'; @@ -29,82 +30,46 @@ class _SessionWidgetState extends State { _isLoading = true; }); - final prefs = await SharedPreferences.getInstance(); - final authToken = prefs.getString('access_token'); - - if (authToken != null) { - try { - final response = await _getSession(authToken); - if (response.statusCode == 200) { - setState(() { - _email = response.data['data']['attributes']['email']; - _isLoading = false; - }); - return; - } - } catch (error) { - if (error is DioException && error.response?.statusCode == 401) { - final renewalToken = prefs.getString('renewal_token'); - if (renewalToken != null) { - final renewedSession = await _renewSession(renewalToken); - if (renewedSession) { - await _checkSession(); - return; - } - } - } - print('Error checking session: $error'); - await _logout(); + try { + final response = await _getSession(); + if (response.statusCode == 200) { + setState(() { + _email = response.data['data']['attributes']['email']; + _isLoading = false; + }); + return; } - } + } catch (error) { + talker.error('Error checking session: $error'); - setState(() { - _email = null; - _isLoading = false; - }); + setState(() { + _email = null; + _isLoading = false; + }); + } } - Future _getSession(String authToken) { + Future _getSession() { return widget.dio.get( '${widget.apiBaseUrl}/fixme/session', options: Options(headers: { - 'Authorization': authToken, 'Accept': 'application/json', 'Content-Type': 'application/json', }), ); } - Future _renewSession(String renewalToken) async { - try { - final response = await widget.dio.post( - '${widget.apiBaseUrl}/powapi/session/renew', - options: Options(headers: { - 'Authorization': renewalToken, - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }), - ); - - if (response.statusCode == 200) { - final accessToken = response.data['data']['access_token']; - final newRenewalToken = response.data['data']['renewal_token']; - - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('access_token', accessToken); - await prefs.setString('renewal_token', newRenewalToken); - return true; - } - } catch (error) { - print('Error renewing session: $error'); - } - return false; - } - Future _logout() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove('access_token'); - _checkSession(); + await prefs.remove('renewal_token'); + + setState(() { + _email = null; + _isLoading = false; + }); + + await _checkSession(); } void _openAuthForm() { diff --git a/waydowntown_app/test/widgets/session_widget_test.dart b/waydowntown_app/test/widgets/session_widget_test.dart index d447e973..1e56f80b 100644 --- a/waydowntown_app/test/widgets/session_widget_test.dart +++ b/waydowntown_app/test/widgets/session_widget_test.dart @@ -4,15 +4,34 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:waydowntown/refresh_token_interceptor.dart'; import 'package:waydowntown/widgets/session_widget.dart'; void main() { late Dio dio; late DioAdapter dioAdapter; + late Dio renewalDio; + late DioAdapter renewalDioAdapter; + + late Dio postRenewalDio; + late DioAdapter postRenewalDioAdapter; + setUp(() { - dio = Dio(BaseOptions()); + renewalDio = Dio(BaseOptions(baseUrl: 'http://example.com')); + renewalDio.interceptors + .add(PrettyDioLogger(requestBody: true, requestHeader: true)); + renewalDioAdapter = DioAdapter(dio: renewalDio, printLogs: true); + + postRenewalDio = Dio(BaseOptions(baseUrl: 'http://example.com')); + postRenewalDio.interceptors + .add(PrettyDioLogger(requestBody: true, requestHeader: true)); + postRenewalDioAdapter = DioAdapter(dio: postRenewalDio); + + dio = Dio(BaseOptions(baseUrl: 'http://example.com')); dio.interceptors.add(PrettyDioLogger()); + dio.interceptors.add(RefreshTokenInterceptor( + dio: dio, renewalDio: renewalDio, postRenewalDio: postRenewalDio)); dioAdapter = DioAdapter(dio: dio); }); @@ -20,8 +39,17 @@ void main() { (WidgetTester tester) async { SharedPreferences.setMockInitialValues({}); + dioAdapter.onGet( + 'http://example.com/fixme/session', + (server) => server.reply(401, { + 'data': { + 'attributes': {'email': 'test@example.com'} + } + }), + ); + await tester.pumpWidget(MaterialApp( - home: SessionWidget(dio: dio, apiBaseUrl: 'https://example.com'), + home: SessionWidget(dio: dio, apiBaseUrl: 'http://example.com'), )); await tester.pumpAndSettle(); @@ -37,7 +65,7 @@ void main() { }); dioAdapter.onGet( - 'https://example.com/fixme/session', + 'http://example.com/fixme/session', (server) => server.reply(200, { 'data': { 'attributes': {'email': 'test@example.com'} @@ -46,7 +74,7 @@ void main() { ); await tester.pumpWidget(MaterialApp( - home: SessionWidget(dio: dio, apiBaseUrl: 'https://example.com'), + home: SessionWidget(dio: dio, apiBaseUrl: 'http://example.com'), )); await tester.pumpAndSettle(); @@ -62,16 +90,21 @@ void main() { 'access_token': 'abc123', }); dioAdapter.onGet( - 'https://example.com/fixme/session', + 'http://example.com/fixme/session', (server) => server.reply(200, { 'data': { 'attributes': {'email': 'test@example.com'} } }), + headers: { + 'Authorization': 'abc123', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, ); await tester.pumpWidget(MaterialApp( - home: SessionWidget(dio: dio, apiBaseUrl: 'https://example.com'), + home: SessionWidget(dio: dio, apiBaseUrl: 'http://example.com'), )); await tester.pumpAndSettle(); @@ -90,29 +123,7 @@ void main() { final prefs = await SharedPreferences.getInstance(); expect(prefs.getString('access_token'), isNull); - }); - - testWidgets('SessionWidget clears cookie on 401', - (WidgetTester tester) async { - SharedPreferences.setMockInitialValues({ - 'access_token': 'abc123', - }); - dioAdapter.onGet( - 'https://example.com/fixme/session', - (server) => server.reply(401, {}), - ); - - await tester.pumpWidget(MaterialApp( - home: SessionWidget(dio: dio, apiBaseUrl: 'https://example.com'), - )); - - await tester.pumpAndSettle(); - - expect(find.text('Log in'), findsOneWidget); - expect(find.byType(ElevatedButton), findsOneWidget); - - final prefs = await SharedPreferences.getInstance(); - expect(prefs.getString('access_token'), isNull); + expect(prefs.getString('renewal_token'), isNull); }); testWidgets('SessionWidget renews session on 401 and retries', @@ -123,7 +134,7 @@ void main() { }); dioAdapter.onGet( - 'https://example.com/fixme/session', + 'http://example.com/fixme/session', (server) => server.reply(401, {}), headers: { 'Authorization': 'expired_token', @@ -132,14 +143,15 @@ void main() { }, ); - dioAdapter.onPost( - 'https://example.com/powapi/session/renew', + renewalDioAdapter.onPost( + '/powapi/session/renew', (server) => server.reply(200, { 'data': { 'access_token': 'new_access_token', 'renewal_token': 'new_renewal_token', } }), + data: null, headers: { 'Authorization': 'valid_renewal_token', 'Accept': 'application/json', @@ -147,8 +159,8 @@ void main() { }, ); - dioAdapter.onGet( - 'https://example.com/fixme/session', + postRenewalDioAdapter.onGet( + 'http://example.com/fixme/session', (server) => server.reply(200, { 'data': { 'attributes': {'email': 'test@example.com'} @@ -162,7 +174,7 @@ void main() { ); await tester.pumpWidget(MaterialApp( - home: SessionWidget(dio: dio, apiBaseUrl: 'https://example.com'), + home: SessionWidget(dio: dio, apiBaseUrl: 'http://example.com'), )); await tester.pumpAndSettle();