diff --git a/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart b/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart index cc21a2b82a9..11e40ed192b 100644 --- a/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart +++ b/packages/neon_framework/packages/account_repository/test/utils/http_client_builder_test.dart @@ -54,7 +54,7 @@ void main() { .having( (c) => c.httpClient, 'httpClient', - isA().having((c) => c.interceptors, 'interceptors', hasLength(2)), + isA().having((c) => c.interceptors, 'interceptors', hasLength(3)), ), ); @@ -81,7 +81,7 @@ void main() { .having( (c) => c.httpClient, 'httpClient', - isA().having((c) => c.interceptors, 'interceptors', hasLength(2)), + isA().having((c) => c.interceptors, 'interceptors', hasLength(3)), ), ); diff --git a/packages/neon_framework/packages/neon_http_client/lib/src/interceptors/authorization_throttling_interceptor.dart b/packages/neon_framework/packages/neon_http_client/lib/src/interceptors/authorization_throttling_interceptor.dart new file mode 100644 index 00000000000..4e813f21df9 --- /dev/null +++ b/packages/neon_framework/packages/neon_http_client/lib/src/interceptors/authorization_throttling_interceptor.dart @@ -0,0 +1,67 @@ +import 'package:http/http.dart'; +import 'package:meta/meta.dart'; +import 'package:neon_http_client/src/interceptors/http_interceptor.dart'; +import 'package:nextcloud/nextcloud.dart'; + +@internal +final class AuthorizationThrottlingInterceptor implements HttpInterceptor { + AuthorizationThrottlingInterceptor({ + required this.baseURL, + }); + + final Uri baseURL; + var _blocked = false; + + bool _matchesBaseURL(Uri uri) => uri.toString().startsWith(baseURL.toString()); + + @override + bool shouldInterceptRequest(BaseRequest request) { + if (!_matchesBaseURL(request.url)) { + return false; + } + + final authorization = request.headers['authorization']; + return authorization != null && authorization.isNotEmpty && _blocked; + } + + @override + Never interceptRequest({required BaseRequest request}) { + assert( + shouldInterceptRequest(request), + 'Request should not be intercepted.', + ); + + throw DynamiteStatusCodeException(Response('', 401)); + } + + @override + bool shouldInterceptResponse(StreamedResponse response) { + final request = response.request; + if (request == null) { + return false; + } + + if (!_matchesBaseURL(request.url)) { + return false; + } + + return true; + } + + @override + StreamedResponse interceptResponse({required StreamedResponse response, required Uri url}) { + assert( + shouldInterceptResponse(response), + 'Response should not be intercepted.', + ); + + final authorization = response.request!.headers['authorization']; + if (authorization != null && authorization.isNotEmpty && response.statusCode == 401) { + _blocked = true; + } else if (response.statusCode == 200 && response.request!.url.path.endsWith('/index.php/login/v2/poll')) { + _blocked = false; + } + + return response; + } +} diff --git a/packages/neon_framework/packages/neon_http_client/lib/src/interceptors/http_interceptor.dart b/packages/neon_framework/packages/neon_http_client/lib/src/interceptors/http_interceptor.dart index d51b4480fc5..7f8318dee78 100644 --- a/packages/neon_framework/packages/neon_http_client/lib/src/interceptors/http_interceptor.dart +++ b/packages/neon_framework/packages/neon_http_client/lib/src/interceptors/http_interceptor.dart @@ -11,6 +11,10 @@ abstract interface class HttpInterceptor { /// /// Provided requests are not finalized yet. It is an error for an interceptor /// to finalize it by executing them. + /// + /// Exceptions might be thrown during interception. + /// If the exception is an [http.ClientException] it will be thrown as is, + /// otherwise it wrapped as an `InterceptionException`. FutureOr interceptRequest({required http.BaseRequest request}); /// Whether this interceptor should intercept response. @@ -19,6 +23,10 @@ abstract interface class HttpInterceptor { /// Intercepts the given [response]. /// /// Until package:http 2.0 makes [http.BaseResponseWithUrl] mandatory the request url is used. + /// + /// Exceptions might be thrown during interception. + /// If the exception is an [http.ClientException] it will be thrown as is, + /// otherwise it wrapped as an `InterceptionException`. FutureOr interceptResponse({ required http.StreamedResponse response, required Uri url, diff --git a/packages/neon_framework/packages/neon_http_client/lib/src/neon_http_client.dart b/packages/neon_framework/packages/neon_http_client/lib/src/neon_http_client.dart index 02a7b4c9e06..1318011b168 100644 --- a/packages/neon_framework/packages/neon_http_client/lib/src/neon_http_client.dart +++ b/packages/neon_framework/packages/neon_http_client/lib/src/neon_http_client.dart @@ -4,6 +4,7 @@ import 'package:built_collection/built_collection.dart'; import 'package:cookie_store/cookie_store.dart'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; +import 'package:neon_http_client/src/interceptors/authorization_throttling_interceptor.dart'; import 'package:neon_http_client/src/interceptors/interceptors.dart'; import 'package:neon_http_client/src/utils/utils.dart'; import 'package:universal_io/io.dart'; @@ -48,6 +49,8 @@ final class NeonHttpClient with http.BaseClient { builder.addAll(interceptors); } + builder.add(AuthorizationThrottlingInterceptor(baseURL: baseURL)); + if (cookieStore != null) { builder.add( CookieStoreInterceptor(cookieStore: cookieStore), @@ -101,7 +104,11 @@ final class NeonHttpClient with http.BaseClient { interceptedRequest = await interceptor.interceptRequest( request: interceptedRequest, ); - } catch (_, stackTrace) { + } catch (error, stackTrace) { + if (error is http.ClientException) { + rethrow; + } + Error.throwWithStackTrace( InterceptionException('Failed to intercept request', request.url), stackTrace, @@ -131,7 +138,11 @@ final class NeonHttpClient with http.BaseClient { response: interceptedResponse, url: url, ); - } catch (_, stackTrace) { + } catch (error, stackTrace) { + if (error is http.ClientException) { + rethrow; + } + Error.throwWithStackTrace( InterceptionException('Failed to intercept response', request.url), stackTrace, diff --git a/packages/neon_framework/packages/neon_http_client/test/interceptors/authorization_throttling_interceptor_test.dart b/packages/neon_framework/packages/neon_http_client/test/interceptors/authorization_throttling_interceptor_test.dart new file mode 100644 index 00000000000..3b4ae656fa0 --- /dev/null +++ b/packages/neon_framework/packages/neon_http_client/test/interceptors/authorization_throttling_interceptor_test.dart @@ -0,0 +1,233 @@ +import 'package:http/http.dart'; +import 'package:neon_http_client/src/interceptors/authorization_throttling_interceptor.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:test/test.dart'; + +void main() { + final baseURL = Uri.parse('https://cloud.example.com:8443/nextcloud'); + + group(AuthorizationThrottlingInterceptor, () { + test('blocks after unauthorized response', () async { + final interceptor = AuthorizationThrottlingInterceptor(baseURL: baseURL); + + final authorizedRequest = Request('GET', baseURL); + authorizedRequest.headers['authorization'] = 'test'; + final authorizedResponse = StreamedResponse( + const Stream.empty(), + 401, + request: authorizedRequest, + ); + + expect( + interceptor.shouldInterceptRequest(authorizedRequest), + isFalse, + ); + + expect( + interceptor.shouldInterceptResponse(authorizedResponse), + isTrue, + ); + expect( + interceptor.interceptResponse(response: authorizedResponse, url: authorizedRequest.url), + isA(), + ); + + expect( + interceptor.shouldInterceptRequest(authorizedRequest), + isTrue, + ); + await expectLater( + () async => interceptor.interceptRequest(request: authorizedRequest), + throwsA(isA().having((e) => e.response.statusCode, 'response.statusCode', 401)), + ); + }); + + test('unblocks after successful poll', () async { + final interceptor = AuthorizationThrottlingInterceptor(baseURL: baseURL); + + final authorizedRequest = Request('GET', baseURL); + authorizedRequest.headers['authorization'] = 'test'; + final authorizedResponse = StreamedResponse( + const Stream.empty(), + 401, + request: authorizedRequest, + ); + + final pollRequest = Request( + 'POST', + Uri.parse('$baseURL/index.php/login/v2/poll'), + ); + final pollResponse = StreamedResponse( + const Stream.empty(), + 200, + request: pollRequest, + ); + + expect( + interceptor.shouldInterceptRequest(authorizedRequest), + isFalse, + ); + + expect( + interceptor.shouldInterceptResponse(authorizedResponse), + isTrue, + ); + expect( + interceptor.interceptResponse(response: authorizedResponse, url: authorizedRequest.url), + isA(), + ); + + expect( + interceptor.shouldInterceptRequest(authorizedRequest), + isTrue, + ); + await expectLater( + () async => interceptor.interceptRequest(request: authorizedRequest), + throwsA(isA().having((e) => e.response.statusCode, 'response.statusCode', 401)), + ); + + expect( + interceptor.shouldInterceptRequest(pollRequest), + isFalse, + ); + expect( + interceptor.shouldInterceptResponse(pollResponse), + isTrue, + ); + expect( + interceptor.interceptResponse(response: pollResponse, url: pollRequest.url), + isA(), + ); + + expect( + interceptor.shouldInterceptRequest(authorizedRequest), + isFalse, + ); + }); + + test('never blocks requests not matching baseURL', () async { + final interceptor = AuthorizationThrottlingInterceptor(baseURL: baseURL); + + final otherServerRequest = Request('GET', Uri.parse('http://example.com')); + otherServerRequest.headers['authorization'] = 'test'; + final otherServerResponse = StreamedResponse( + const Stream.empty(), + 401, + request: otherServerRequest, + ); + + final correctServerRequest = Request('GET', baseURL); + correctServerRequest.headers['authorization'] = 'test'; + final correctServerResponse = StreamedResponse( + const Stream.empty(), + 401, + request: correctServerRequest, + ); + + expect( + interceptor.shouldInterceptRequest(otherServerRequest), + isFalse, + ); + expect( + interceptor.shouldInterceptResponse(otherServerResponse), + isFalse, + ); + + expect( + interceptor.shouldInterceptRequest(correctServerRequest), + isFalse, + ); + expect( + interceptor.shouldInterceptResponse(correctServerResponse), + isTrue, + ); + expect( + interceptor.interceptResponse(response: correctServerResponse, url: correctServerRequest.url), + isA(), + ); + expect( + interceptor.shouldInterceptRequest(correctServerRequest), + isTrue, + ); + await expectLater( + () async => interceptor.interceptRequest(request: correctServerRequest), + throwsA(isA().having((e) => e.response.statusCode, 'response.statusCode', 401)), + ); + + expect( + interceptor.shouldInterceptRequest(otherServerRequest), + isFalse, + ); + expect( + interceptor.shouldInterceptResponse(otherServerResponse), + isFalse, + ); + }); + + test('never blocks requests without authorization', () async { + final interceptor = AuthorizationThrottlingInterceptor(baseURL: baseURL); + + final unauthorizedRequest = Request('GET', baseURL); + final unauthorizedResponse = StreamedResponse( + const Stream.empty(), + 401, + request: unauthorizedRequest, + ); + + final authorizedRequest = Request('GET', baseURL); + authorizedRequest.headers['authorization'] = 'test'; + final authorizedResponse = StreamedResponse( + const Stream.empty(), + 401, + request: authorizedRequest, + ); + + expect( + interceptor.shouldInterceptRequest(unauthorizedRequest), + isFalse, + ); + expect( + interceptor.shouldInterceptResponse(unauthorizedResponse), + isTrue, + ); + expect( + interceptor.interceptResponse(response: unauthorizedResponse, url: unauthorizedRequest.url), + isA(), + ); + + expect( + interceptor.shouldInterceptRequest(authorizedRequest), + isFalse, + ); + expect( + interceptor.shouldInterceptResponse(authorizedResponse), + isTrue, + ); + expect( + interceptor.interceptResponse(response: authorizedResponse, url: authorizedRequest.url), + isA(), + ); + expect( + interceptor.shouldInterceptRequest(authorizedRequest), + isTrue, + ); + await expectLater( + () async => interceptor.interceptRequest(request: authorizedRequest), + throwsA(isA().having((e) => e.response.statusCode, 'response.statusCode', 401)), + ); + + expect( + interceptor.shouldInterceptRequest(unauthorizedRequest), + isFalse, + ); + expect( + interceptor.shouldInterceptResponse(unauthorizedResponse), + isTrue, + ); + expect( + interceptor.interceptResponse(response: unauthorizedResponse, url: unauthorizedRequest.url), + isA(), + ); + }); + }); +} diff --git a/packages/neon_framework/packages/neon_http_client/test/neon_http_client_test.dart b/packages/neon_framework/packages/neon_http_client/test/neon_http_client_test.dart index 0c7f4bea413..d5bcf428ce4 100644 --- a/packages/neon_framework/packages/neon_http_client/test/neon_http_client_test.dart +++ b/packages/neon_framework/packages/neon_http_client/test/neon_http_client_test.dart @@ -2,8 +2,10 @@ import 'package:cookie_store/cookie_store.dart'; import 'package:http/http.dart'; import 'package:http/testing.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:neon_http_client/src/interceptors/authorization_throttling_interceptor.dart'; import 'package:neon_http_client/src/interceptors/interceptors.dart'; import 'package:neon_http_client/src/neon_http_client.dart'; +import 'package:nextcloud/nextcloud.dart'; import 'package:test/test.dart'; class _MockCookieStore extends Mock implements CookieStore {} @@ -45,8 +47,8 @@ void main() { ); expect( - client.interceptors.first, - isA(), + client.interceptors, + contains(isA()), ); }); @@ -58,9 +60,23 @@ void main() { cookieStore: cookieStore, ); + expect( + client.interceptors, + contains(isA()), + ); + }); + + test('adds authorization throttling interceptor before cookie store', () { + final cookieStore = _MockCookieStore(); + + client = NeonHttpClient( + baseURL: Uri(), + cookieStore: cookieStore, + ); + expect( client.interceptors.first, - isA(), + isA(), ); }); @@ -137,7 +153,26 @@ void main() { ).called(1); }); - test('rethrows errors as InterceptionFailure', () async { + test('rethrows http.ClientExceptions', () async { + final exception = DynamiteStatusCodeException(Response('', 404)); + when(() => interceptor.shouldInterceptRequest(any())).thenReturn(true); + when( + () => interceptor.interceptRequest(request: any(named: 'request')), + ).thenThrow(exception); + + expect(client.get(uri), throwsA(exception)); + + when(() => interceptor.shouldInterceptRequest(any())).thenReturn(true); + when(() => interceptor.interceptRequest(request: any(named: 'request'))).thenReturn(fakeRequest()); + when(() => interceptor.shouldInterceptResponse(any())).thenReturn(true); + when( + () => interceptor.interceptResponse(response: any(named: 'response'), url: any(named: 'url')), + ).thenThrow(exception); + + expect(client.get(uri), throwsA(exception)); + }); + + test('rethrows non-http.ClientExceptions as InterceptionFailure', () async { when(() => interceptor.shouldInterceptRequest(any())).thenReturn(true); when( () => interceptor.interceptRequest(request: any(named: 'request')),