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/neon_http_client.dart b/packages/neon_framework/packages/neon_http_client/lib/src/neon_http_client.dart index deedc567e43..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), 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 0fa8323cc1f..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,6 +2,7 @@ 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'; @@ -46,8 +47,8 @@ void main() { ); expect( - client.interceptors.first, - isA(), + client.interceptors, + contains(isA()), ); }); @@ -59,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(), ); });