Improved handling for authentication errors (#3120)

* Improved handling for 401 and 403 response codes

* Rename _handleError to _throwAuthException

* Improved RFC 7235 section 4.1 parsing
diff --git a/lib/src/authentication/client.dart b/lib/src/authentication/client.dart
index db7e9d8..3de5b2e 100644
--- a/lib/src/authentication/client.dart
+++ b/lib/src/authentication/client.dart
@@ -6,8 +6,11 @@
 
 import 'dart:io';
 
+import 'package:collection/collection.dart';
 import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
 
+import '../exceptions.dart';
 import '../http.dart';
 import '../log.dart' as log;
 import '../system_cache.dart';
@@ -18,6 +21,11 @@
 ///
 /// Requests to URLs not under [serverBaseUrl] will not be authenticated.
 class _AuthenticatedClient extends http.BaseClient {
+  /// Constructs Http client wrapper that injects `authorization` header to
+  /// requests and handles authentication errors.
+  ///
+  /// [credential] might be `null`. In that case `authorization` header will not
+  /// be injected to requests.
   _AuthenticatedClient(this._inner, this.credential);
 
   final http.BaseClient _inner;
@@ -34,17 +42,77 @@
     // to given serverBaseUrl. Otherwise credential leaks might ocurr when
     // archive_url hosted on 3rd party server that should not receive
     // credentials of the first party.
-    if (credential.canAuthenticate(request.url.toString())) {
+    if (credential != null &&
+        credential.canAuthenticate(request.url.toString())) {
       request.headers[HttpHeaders.authorizationHeader] =
           await credential.getAuthorizationHeaderValue();
     }
-    return _inner.send(request);
+
+    try {
+      final response = await _inner.send(request);
+      if (response.statusCode == 401) {
+        _throwAuthException(response);
+      }
+      return response;
+    } on PubHttpException catch (e) {
+      if (e.response?.statusCode == 403) {
+        _throwAuthException(e.response);
+      }
+      rethrow;
+    }
+  }
+
+  /// Throws [AuthenticationException] that includes response status code and
+  /// message parsed from WWW-Authenticate header usign
+  /// [RFC 7235 section 4.1][RFC] specifications.
+  ///
+  /// [RFC]: https://datatracker.ietf.org/doc/html/rfc7235#section-4.1
+  void _throwAuthException(http.BaseResponse response) {
+    String serverMessage;
+    if (response.headers.containsKey(HttpHeaders.wwwAuthenticateHeader)) {
+      try {
+        final header = response.headers[HttpHeaders.wwwAuthenticateHeader];
+        final challenge = AuthenticationChallenge.parseHeader(header)
+            .firstWhereOrNull((challenge) =>
+                challenge.scheme == 'bearer' &&
+                challenge.parameters['realm'] == 'pub' &&
+                challenge.parameters['message'] != null);
+        serverMessage = challenge?.parameters['message'];
+      } on FormatException {
+        // Ignore errors might be caused when parsing invalid header values
+      }
+    }
+    if (serverMessage != null) {
+      // Only allow printable ASCII, map anything else to whitespace, take
+      // at-most 1024 characters.
+      serverMessage = String.fromCharCodes(serverMessage.runes
+          .map((r) => 32 <= r && r <= 127 ? r : 32)
+          .take(1024));
+    }
+    throw AuthenticationException(response.statusCode, serverMessage);
   }
 
   @override
   void close() => _inner.close();
 }
 
+/// Token authenticated related exception.
+class AuthenticationException implements Exception {
+  const AuthenticationException(this.statusCode, this.serverMessage);
+
+  final int statusCode;
+  final String serverMessage;
+
+  @override
+  String toString() {
+    var message = 'Authentication error ($statusCode)';
+    if (serverMessage != null) {
+      message += ': $serverMessage';
+    }
+    return message;
+  }
+}
+
 /// Invoke [fn] with a [http.Client] capable of authenticating against
 /// [hostedUrl].
 ///
@@ -56,65 +124,31 @@
   Future<T> Function(http.Client) fn,
 ) async {
   final credential = systemCache.tokenStore.findCredential(hostedUrl);
-  final http.Client client = credential == null
-      ? httpClient
-      : _AuthenticatedClient(httpClient, credential);
+  final http.Client client = _AuthenticatedClient(httpClient, credential);
 
   try {
     return await fn(client);
-  } on PubHttpException catch (error) {
-    if (error.response?.statusCode == 401 ||
-        error.response?.statusCode == 403) {
-      // TODO(themisir): Do we need to match error.response.request.url with
-      // the hostedUrl? Or at least we might need to log request.url to give
-      // user additional insights on what's happening.
+  } on AuthenticationException catch (error) {
+    String message;
 
-      String serverMessage;
-
-      try {
-        final wwwAuthenticateHeaderValue =
-            error.response.headers[HttpHeaders.wwwAuthenticateHeader];
-        if (wwwAuthenticateHeaderValue != null) {
-          final parsedValue = HeaderValue.parse(wwwAuthenticateHeaderValue,
-              parameterSeparator: ',');
-          if (parsedValue.parameters['realm'] == 'pub') {
-            serverMessage = parsedValue.parameters['message'];
-          }
-        }
-      } catch (_) {
-        // Ignore errors might be caused when parsing invalid header values
+    if (error.statusCode == 401) {
+      if (systemCache.tokenStore.removeCredential(hostedUrl)) {
+        log.warning('Invalid token for $hostedUrl deleted.');
       }
-
-      if (error.response.statusCode == 401) {
-        if (systemCache.tokenStore.removeCredential(hostedUrl)) {
-          log.warning('Invalid token for $hostedUrl deleted.');
-        }
-
-        log.error(
-          'Authentication requested by hosted server at: $hostedUrl\n'
-          'You can use the following command to add token for the server:\n'
-          '\n    pub token add $hostedUrl\n',
-        );
-      }
-      if (error.response.statusCode == 403) {
-        log.error(
-          'Insufficient permissions to the resource in hosted server at: '
-          '$hostedUrl\n'
-          'You can use the following command to update token for the server:\n'
-          '\n    pub token add $hostedUrl\n',
-        );
-      }
-
-      if (serverMessage?.isNotEmpty == true) {
-        // Only allow printable ASCII, map anything else to whitespace, take
-        // at-most 1024 characters.
-        final truncatedMessage = String.fromCharCodes(serverMessage.runes
-            .map((r) => 32 >= r && r <= 127 ? r : 32)
-            .take(1024));
-
-        log.error(truncatedMessage);
-      }
+      message = '$hostedUrl package repository requested authentication! '
+          'You can provide credential using:\n'
+          '    pub token add $hostedUrl';
     }
-    rethrow;
+    if (error.statusCode == 403) {
+      message = 'Insufficient permissions to the resource in $hostedUrl '
+          'package repository. You can modify credential using:\n'
+          '    pub token add $hostedUrl';
+    }
+
+    if (error.serverMessage?.isNotEmpty == true) {
+      message += '\n${error.serverMessage}';
+    }
+
+    throw DataException(message);
   }
 }