Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(neon_http_client): Add AuthenticationInterceptor #2454

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ void main() {
.having(
(c) => c.httpClient,
'httpClient',
isA<NeonHttpClient>().having((c) => c.interceptors, 'interceptors', hasLength(2)),
isA<NeonHttpClient>().having((c) => c.interceptors, 'interceptors', hasLength(3)),
),
);

Expand All @@ -81,7 +81,7 @@ void main() {
.having(
(c) => c.httpClient,
'httpClient',
isA<NeonHttpClient>().having((c) => c.interceptors, 'interceptors', hasLength(2)),
isA<NeonHttpClient>().having((c) => c.interceptors, 'interceptors', hasLength(3)),
),
);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<http.BaseRequest> interceptRequest({required http.BaseRequest request});

/// Whether this interceptor should intercept response.
Expand All @@ -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<http.StreamedResponse> interceptResponse({
required http.StreamedResponse response,
required Uri url,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
}

provokateurin marked this conversation as resolved.
Show resolved Hide resolved
Error.throwWithStackTrace(
InterceptionException('Failed to intercept request', request.url),
stackTrace,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<StreamedResponse>(),
);

expect(
interceptor.shouldInterceptRequest(authorizedRequest),
isTrue,
);
await expectLater(
() async => interceptor.interceptRequest(request: authorizedRequest),
throwsA(isA<DynamiteStatusCodeException>().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<StreamedResponse>(),
);

expect(
interceptor.shouldInterceptRequest(authorizedRequest),
isTrue,
);
await expectLater(
() async => interceptor.interceptRequest(request: authorizedRequest),
throwsA(isA<DynamiteStatusCodeException>().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<StreamedResponse>(),
);

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<StreamedResponse>(),
);
expect(
interceptor.shouldInterceptRequest(correctServerRequest),
isTrue,
);
await expectLater(
() async => interceptor.interceptRequest(request: correctServerRequest),
throwsA(isA<DynamiteStatusCodeException>().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<StreamedResponse>(),
);

expect(
interceptor.shouldInterceptRequest(authorizedRequest),
isFalse,
);
expect(
interceptor.shouldInterceptResponse(authorizedResponse),
isTrue,
);
expect(
interceptor.interceptResponse(response: authorizedResponse, url: authorizedRequest.url),
isA<StreamedResponse>(),
);
expect(
interceptor.shouldInterceptRequest(authorizedRequest),
isTrue,
);
await expectLater(
() async => interceptor.interceptRequest(request: authorizedRequest),
throwsA(isA<DynamiteStatusCodeException>().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<StreamedResponse>(),
);
});
});
}
Loading