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);
}
}