diff --git a/chopper/lib/src/authenticator.dart b/chopper/lib/src/authenticator.dart index d69e6d76..1d0ba176 100644 --- a/chopper/lib/src/authenticator.dart +++ b/chopper/lib/src/authenticator.dart @@ -2,12 +2,62 @@ import 'dart:async'; import 'package:chopper/chopper.dart'; -/// This method should return a [Request] that includes credentials to satisfy an authentication challenge received in -/// [response]. It should return `null` if the challenge cannot be satisfied. +/// +/// Callback that is called when an authentication challenge is received +/// based on the given [request], [response], and optionally the +/// [originalRequest]. +/// +typedef AuthenticationCallback = FutureOr Function( + Request request, + Response response, [ + Request? originalRequest, +]); + +/// +/// Handles authentication challenges raised by the [ChopperClient]. +/// +/// Optionally, you can override either [onAuthenticationSuccessful] or +/// [onAuthenticationFailed] in order to listen to when a particular +/// authentication request succeeds or fails. +/// +/// For example, you can use these in order to reset or mutate your +/// instance's internal state for the purposes of keeping track of +/// the number of retries made to authenticate a request. +/// +/// Furthermore, you can use these callbacks to determine whether +/// your authentication [Request] from [authenticate] actually succeeded +/// or failed. +/// abstract class Authenticator { + /// + /// Returns a [Request] that includes credentials to satisfy + /// an authentication challenge received in [response], based on + /// the incoming [request] or optionally, the [originalRequest] + /// (which was not modified with any previous [RequestInterceptor]s). + /// + /// Otherwise, return `null` if the challenge cannot be satisfied. + /// FutureOr authenticate( Request request, Response response, [ Request? originalRequest, ]); + + /// + /// Optional callback called by [ChopperClient] when the outgoing + /// request from [authenticate] was successful. + /// + /// You can use this to determine whether that request actually succeeded + /// in authenticating the user. + /// + AuthenticationCallback? get onAuthenticationSuccessful => null; + + /// + /// Optional callback called by [ChopperClient] when the outgoing + /// request from [authenticate] failed to authenticate. + /// + /// You can use this to determine whether that request failed to recover + /// the user's session. + /// + AuthenticationCallback? get onAuthenticationFailed => null; } diff --git a/chopper/lib/src/base.dart b/chopper/lib/src/base.dart index 504590e2..0d2c12af 100644 --- a/chopper/lib/src/base.dart +++ b/chopper/lib/src/base.dart @@ -320,10 +320,13 @@ base class ChopperClient { ); // To prevent double call with typed response if (_responseIsSuccessful(res.statusCode)) { + await authenticator!.onAuthenticationSuccessful + ?.call(updatedRequest, res, request); return _processResponse(res); } else { res = await _handleErrorResponse(res); - + await authenticator!.onAuthenticationFailed + ?.call(updatedRequest, res, request); return _processResponse(res); } } diff --git a/chopper/test/authenticator_test.dart b/chopper/test/authenticator_test.dart index 8f4ebb20..7b454462 100644 --- a/chopper/test/authenticator_test.dart +++ b/chopper/test/authenticator_test.dart @@ -85,6 +85,7 @@ void main() async { }); final chopper = buildClient(httpClient); + final authenticator = chopper.authenticator as FakeAuthenticator; final response = await chopper.get( Uri( path: '/test/get', @@ -97,6 +98,59 @@ void main() async { expect(response.statusCode, equals(200)); expect(tested['authenticated'], equals(true)); expect(tested['unauthenticated'], equals(true)); + expect(authenticator.capturedRequest, + authenticator.capturedAuthenticateRequest); + expect(authenticator.capturedOriginalRequest, + authenticator.capturedAuthenticateOriginalRequest); + expect(authenticator.capturedResponse, response); + expect(authenticator.onAuthenticationSuccessfulCalled, isTrue); + + httpClient.close(); + }); + + test('unauthorized total failure', () async { + final httpClient = MockClient((request) async { + expect( + request.url.toString(), + equals('$baseUrl/test/get?key=val'), + ); + expect(request.method, equals('GET')); + expect(request.headers['foo'], equals('bar')); + expect(request.headers['int'], equals('42')); + + if (!authenticated) { + tested['unauthenticated'] = true; + authenticated = true; + + return http.Response('unauthorized', 401); + } else { + tested['authenticated'] = true; + expect(request.headers['authorization'], equals('some_fake_token')); + } + + return http.Response('Access Denied', 403); + }); + + final chopper = buildClient(httpClient); + final authenticator = chopper.authenticator as FakeAuthenticator; + final response = await chopper.get( + Uri( + path: '/test/get', + queryParameters: {'key': 'val'}, + ), + headers: {'int': '42'}, + ); + + expect(response.body, anyOf(isNull, isEmpty)); + expect(response.statusCode, equals(403)); + expect(tested['authenticated'], equals(true)); + expect(tested['unauthenticated'], equals(true)); + expect(authenticator.capturedRequest, + authenticator.capturedAuthenticateRequest); + expect(authenticator.capturedOriginalRequest, + authenticator.capturedAuthenticateOriginalRequest); + expect(authenticator.capturedResponse, response); + expect(authenticator.onAuthenticationFailedCalled, isTrue); httpClient.close(); }); @@ -177,6 +231,7 @@ void main() async { }); final chopper = buildClient(httpClient); + final authenticator = chopper.authenticator as FakeAuthenticator; final response = await chopper.post( Uri( path: '/test/post', @@ -193,6 +248,72 @@ void main() async { expect(response.statusCode, equals(200)); expect(tested['authenticated'], equals(true)); expect(tested['unauthenticated'], equals(true)); + expect(authenticator.capturedRequest, + authenticator.capturedAuthenticateRequest); + expect(authenticator.capturedOriginalRequest, + authenticator.capturedAuthenticateOriginalRequest); + expect(authenticator.capturedResponse, response); + expect(authenticator.onAuthenticationSuccessfulCalled, isTrue); + + httpClient.close(); + }); + + test('unauthorized total failure', () async { + final httpClient = MockClient((request) async { + expect( + request.url.toString(), + equals('$baseUrl/test/post?key=val'), + ); + expect(request.method, equals('POST')); + expect(request.headers['foo'], equals('bar')); + expect(request.headers['int'], equals('42')); + expect( + request.body, + jsonEncode( + { + 'name': 'john', + 'surname': 'doe', + }, + ), + ); + + if (!authenticated) { + tested['unauthenticated'] = true; + authenticated = true; + + return http.Response('unauthorized', 401); + } else { + tested['authenticated'] = true; + expect(request.headers['authorization'], equals('some_fake_token')); + } + + return http.Response('Access Denied', 403); + }); + + final chopper = buildClient(httpClient); + final authenticator = chopper.authenticator as FakeAuthenticator; + final response = await chopper.post( + Uri( + path: '/test/post', + queryParameters: {'key': 'val'}, + ), + headers: {'int': '42'}, + body: { + 'name': 'john', + 'surname': 'doe', + }, + ); + + expect(response.body, anyOf(isNull, isEmpty)); + expect(response.statusCode, equals(403)); + expect(tested['authenticated'], equals(true)); + expect(tested['unauthenticated'], equals(true)); + expect(authenticator.capturedRequest, + authenticator.capturedAuthenticateRequest); + expect(authenticator.capturedOriginalRequest, + authenticator.capturedAuthenticateOriginalRequest); + expect(authenticator.capturedResponse, response); + expect(authenticator.onAuthenticationFailedCalled, isTrue); httpClient.close(); }); @@ -273,6 +394,7 @@ void main() async { }); final chopper = buildClient(httpClient); + final authenticator = chopper.authenticator as FakeAuthenticator; final response = await chopper.put( Uri( path: '/test/put', @@ -289,6 +411,72 @@ void main() async { expect(response.statusCode, equals(200)); expect(tested['authenticated'], equals(true)); expect(tested['unauthenticated'], equals(true)); + expect(authenticator.capturedRequest, + authenticator.capturedAuthenticateRequest); + expect(authenticator.capturedOriginalRequest, + authenticator.capturedAuthenticateOriginalRequest); + expect(authenticator.capturedResponse, response); + expect(authenticator.onAuthenticationSuccessfulCalled, isTrue); + + httpClient.close(); + }); + + test('unauthorized total failure', () async { + final httpClient = MockClient((request) async { + expect( + request.url.toString(), + equals('$baseUrl/test/put?key=val'), + ); + expect(request.method, equals('PUT')); + expect(request.headers['foo'], equals('bar')); + expect(request.headers['int'], equals('42')); + expect( + request.body, + jsonEncode( + { + 'name': 'john', + 'surname': 'doe', + }, + ), + ); + + if (!authenticated) { + tested['unauthenticated'] = true; + authenticated = true; + + return http.Response('unauthorized', 401); + } else { + tested['authenticated'] = true; + expect(request.headers['authorization'], equals('some_fake_token')); + } + + return http.Response('Access Denied', 403); + }); + + final chopper = buildClient(httpClient); + final authenticator = chopper.authenticator as FakeAuthenticator; + final response = await chopper.put( + Uri( + path: '/test/put', + queryParameters: {'key': 'val'}, + ), + headers: {'int': '42'}, + body: { + 'name': 'john', + 'surname': 'doe', + }, + ); + + expect(response.body, anyOf(isNull, isEmpty)); + expect(response.statusCode, equals(403)); + expect(tested['authenticated'], equals(true)); + expect(tested['unauthenticated'], equals(true)); + expect(authenticator.capturedRequest, + authenticator.capturedAuthenticateRequest); + expect(authenticator.capturedOriginalRequest, + authenticator.capturedAuthenticateOriginalRequest); + expect(authenticator.capturedResponse, response); + expect(authenticator.onAuthenticationFailedCalled, isTrue); httpClient.close(); }); @@ -369,6 +557,7 @@ void main() async { }); final chopper = buildClient(httpClient); + final authenticator = chopper.authenticator as FakeAuthenticator; final response = await chopper.patch( Uri( path: '/test/patch', @@ -385,6 +574,72 @@ void main() async { expect(response.statusCode, equals(200)); expect(tested['authenticated'], equals(true)); expect(tested['unauthenticated'], equals(true)); + expect(authenticator.capturedRequest, + authenticator.capturedAuthenticateRequest); + expect(authenticator.capturedResponse, response); + expect(authenticator.capturedOriginalRequest, + authenticator.capturedAuthenticateOriginalRequest); + expect(authenticator.onAuthenticationSuccessfulCalled, isTrue); + + httpClient.close(); + }); + + test('unauthorized total failure', () async { + final httpClient = MockClient((request) async { + expect( + request.url.toString(), + equals('$baseUrl/test/patch?key=val'), + ); + expect(request.method, equals('PATCH')); + expect(request.headers['foo'], equals('bar')); + expect(request.headers['int'], equals('42')); + expect( + request.body, + jsonEncode( + { + 'name': 'john', + 'surname': 'doe', + }, + ), + ); + + if (!authenticated) { + tested['unauthenticated'] = true; + authenticated = true; + + return http.Response('unauthorized', 401); + } else { + tested['authenticated'] = true; + expect(request.headers['authorization'], equals('some_fake_token')); + } + + return http.Response('Access Denied', 403); + }); + + final chopper = buildClient(httpClient); + final authenticator = chopper.authenticator as FakeAuthenticator; + final response = await chopper.patch( + Uri( + path: '/test/patch', + queryParameters: {'key': 'val'}, + ), + headers: {'int': '42'}, + body: { + 'name': 'john', + 'surname': 'doe', + }, + ); + + expect(response.body, anyOf(isNull, isEmpty)); + expect(response.statusCode, equals(403)); + expect(tested['authenticated'], equals(true)); + expect(tested['unauthenticated'], equals(true)); + expect(authenticator.capturedRequest, + authenticator.capturedAuthenticateRequest); + expect(authenticator.capturedResponse, response); + expect(authenticator.capturedOriginalRequest, + authenticator.capturedAuthenticateOriginalRequest); + expect(authenticator.onAuthenticationFailedCalled, isTrue); httpClient.close(); }); diff --git a/chopper/test/fake_authenticator.dart b/chopper/test/fake_authenticator.dart index 22de9356..57237dae 100644 --- a/chopper/test/fake_authenticator.dart +++ b/chopper/test/fake_authenticator.dart @@ -3,6 +3,22 @@ import 'dart:async' show FutureOr; import 'package:chopper/chopper.dart'; class FakeAuthenticator extends Authenticator { + Request? capturedRequest; + + Response? capturedResponse; + + Request? capturedOriginalRequest; + + Request? capturedAuthenticateRequest; + + Response? capturedAuthenticateResponse; + + Request? capturedAuthenticateOriginalRequest; + + bool onAuthenticationSuccessfulCalled = false; + + bool onAuthenticationFailedCalled = false; + @override FutureOr authenticate( Request request, @@ -10,14 +26,41 @@ class FakeAuthenticator extends Authenticator { Request? originalRequest, ]) async { if (response.statusCode == 401) { - return request.copyWith( + capturedAuthenticateResponse = response; + capturedAuthenticateOriginalRequest = originalRequest; + capturedAuthenticateRequest = request.copyWith( headers: { ...request.headers, 'authorization': 'some_fake_token', }, ); + return capturedAuthenticateRequest; } return null; } + + @override + AuthenticationCallback? get onAuthenticationSuccessful => ( + Request request, + Response response, [ + Request? originalRequest, + ]) { + onAuthenticationSuccessfulCalled = true; + capturedRequest = request; + capturedResponse = response; + capturedOriginalRequest = originalRequest; + }; + + @override + AuthenticationCallback? get onAuthenticationFailed => ( + Request request, + Response response, [ + Request? originalRequest, + ]) { + onAuthenticationFailedCalled = true; + capturedRequest = request; + capturedResponse = response; + capturedOriginalRequest = originalRequest; + }; }