diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index ab8774e..3ce8efc 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,3 +5,8 @@
     directory: "/"
     schedule:
       interval: "monthly"
+
+  - package-ecosystem: github-actions
+    directory: /
+    schedule:
+      interval: monthly
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 100efc4..e85490f 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -24,8 +24,8 @@
       matrix:
         sdk: [dev]
     steps:
-      - uses: actions/checkout@v2
-      - uses: dart-lang/setup-dart@v1.2
+      - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8
+      - uses: dart-lang/setup-dart@6a218f2413a3e78e9087f638a238f6b40893203d
         with:
           sdk: ${{ matrix.sdk }}
       - id: install
@@ -52,8 +52,8 @@
         sdk: [dev]
         shard: [0, 1, 2, 3, 4, 5, 6]
     steps:
-      - uses: actions/checkout@v2
-      - uses: dart-lang/setup-dart@v1.2
+      - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8
+      - uses: dart-lang/setup-dart@6a218f2413a3e78e9087f638a238f6b40893203d
         with:
           sdk: ${{ matrix.sdk }}
       - name: Install dependencies
diff --git a/lib/src/authentication/client.dart b/lib/src/authentication/client.dart
index a6001ec..787f394 100644
--- a/lib/src/authentication/client.dart
+++ b/lib/src/authentication/client.dart
@@ -48,19 +48,14 @@
           await _credential!.getAuthorizationHeaderValue();
     }
 
-    try {
-      final response = await _inner.send(request);
-      if (response.statusCode == 401) {
-        _detectInvalidCredentials = true;
-        _throwAuthException(response);
-      }
-      return response;
-    } on PubHttpException catch (e) {
-      if (e.response.statusCode == 403) {
-        _throwAuthException(e.response);
-      }
-      rethrow;
+    final response = await _inner.send(request);
+    if (response.statusCode == 401) {
+      _detectInvalidCredentials = true;
     }
+    if (response.statusCode == 401 || response.statusCode == 403) {
+      _throwAuthException(response);
+    }
+    return response;
   }
 
   /// Throws [AuthenticationException] that includes response status code and
@@ -127,7 +122,7 @@
   Future<T> Function(http.Client) fn,
 ) async {
   final credential = systemCache.tokenStore.findCredential(hostedUrl);
-  final client = _AuthenticatedClient(httpClient, credential);
+  final client = _AuthenticatedClient(globalHttpClient, credential);
 
   try {
     return await fn(client);
diff --git a/lib/src/command.dart b/lib/src/command.dart
index 31f0b17..90f920d 100644
--- a/lib/src/command.dart
+++ b/lib/src/command.dart
@@ -238,7 +238,7 @@
           log.message('Logs written to $transcriptPath.');
         }
       }
-      httpClient.close();
+      globalHttpClient.close();
     }
   }
 
@@ -253,7 +253,9 @@
       exception = exception.innerError!;
     }
 
-    if (exception is HttpException ||
+    if (exception is PackageIntegrityException) {
+      return exit_codes.TEMP_FAIL;
+    } else if (exception is HttpException ||
         exception is http.ClientException ||
         exception is SocketException ||
         exception is TlsException ||
diff --git a/lib/src/command/cache.dart b/lib/src/command/cache.dart
index 12c4fbf..43e8c5a 100644
--- a/lib/src/command/cache.dart
+++ b/lib/src/command/cache.dart
@@ -6,6 +6,7 @@
 import 'cache_add.dart';
 import 'cache_clean.dart';
 import 'cache_list.dart';
+import 'cache_preload.dart';
 import 'cache_repair.dart';
 
 /// Handles the `cache` pub command.
@@ -22,5 +23,8 @@
     addSubcommand(CacheListCommand());
     addSubcommand(CacheCleanCommand());
     addSubcommand(CacheRepairCommand());
+    addSubcommand(
+      CachePreloadCommand(),
+    );
   }
 }
diff --git a/lib/src/command/cache_preload.dart b/lib/src/command/cache_preload.dart
new file mode 100644
index 0000000..54b7867
--- /dev/null
+++ b/lib/src/command/cache_preload.dart
@@ -0,0 +1,49 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import '../command.dart';
+import '../io.dart';
+import '../log.dart' as log;
+import '../source/hosted.dart';
+import '../utils.dart';
+
+/// Handles the `cache preload` pub command.
+class CachePreloadCommand extends PubCommand {
+  @override
+  String get name => 'preload';
+  @override
+  String get description => 'Install packages from a .tar.gz archive.';
+  @override
+  String get argumentsDescription => '<package1.tar.gz> ...';
+  @override
+  String get docUrl => 'https://dart.dev/tools/pub/cmd/pub-cache';
+
+  /// The `cache preload` command is hidden by default, because it's really only intended for
+  /// `flutter` to use when pre-loading `PUB_CACHE` after being installed from `zip` archive.
+  @override
+  bool get hidden => true;
+
+  @override
+  Future<void> runProtected() async {
+    // Make sure there is a package.
+    if (argResults.rest.isEmpty) {
+      usageException('No package to preload given.');
+    }
+
+    for (String packagePath in argResults.rest) {
+      if (!fileExists(packagePath)) {
+        fail('Could not find file $packagePath.');
+      }
+    }
+    for (String archivePath in argResults.rest) {
+      final id = await cache.hosted.preloadPackage(archivePath, cache);
+      final url = (id.description.description as HostedDescription).url;
+
+      final fromPart = HostedSource.isFromPubDev(id) ? '' : ' from $url';
+      log.message('Installed $archivePath in cache as $id$fromPart.');
+    }
+  }
+}
diff --git a/lib/src/command/lish.dart b/lib/src/command/lish.dart
index 4b182b2..7090412 100644
--- a/lib/src/command/lish.dart
+++ b/lib/src/command/lish.dart
@@ -93,34 +93,51 @@
 
     try {
       await log.progress('Uploading', () async {
-        var newUri = host.resolve('api/packages/versions/new');
-        var response = await client.get(newUri, headers: pubApiHeaders);
-        var parameters = parseJsonResponse(response);
+        /// 1. Initiate upload
+        final parametersResponse =
+            await retryForHttp('initiating upload', () async {
+          final request =
+              http.Request('GET', host.resolve('api/packages/versions/new'));
+          request.attachPubApiHeaders();
+          request.attachMetadataHeaders();
+          return await client.fetch(request);
+        });
+        final parameters = parseJsonResponse(parametersResponse);
 
-        var url = _expectField(parameters, 'url', response);
-        if (url is! String) invalidServerResponse(response);
+        /// 2. Upload package
+        var url = _expectField(parameters, 'url', parametersResponse);
+        if (url is! String) invalidServerResponse(parametersResponse);
         cloudStorageUrl = Uri.parse(url);
-        // TODO(nweiz): Cloud Storage can provide an XML-formatted error. We
-        // should report that error and exit.
-        var request = http.MultipartRequest('POST', cloudStorageUrl!);
+        final uploadResponse =
+            await retryForHttp('uploading package', () async {
+          // TODO(nweiz): Cloud Storage can provide an XML-formatted error. We
+          // should report that error and exit.
+          var request = http.MultipartRequest('POST', cloudStorageUrl!);
 
-        var fields = _expectField(parameters, 'fields', response);
-        if (fields is! Map) invalidServerResponse(response);
-        fields.forEach((key, value) {
-          if (value is! String) invalidServerResponse(response);
-          request.fields[key] = value;
+          var fields = _expectField(parameters, 'fields', parametersResponse);
+          if (fields is! Map) invalidServerResponse(parametersResponse);
+          fields.forEach((key, value) {
+            if (value is! String) invalidServerResponse(parametersResponse);
+            request.fields[key] = value;
+          });
+
+          request.followRedirects = false;
+          request.files.add(http.MultipartFile.fromBytes('file', packageBytes,
+              filename: 'package.tar.gz'));
+          return await client.fetch(request);
         });
 
-        request.followRedirects = false;
-        request.files.add(http.MultipartFile.fromBytes('file', packageBytes,
-            filename: 'package.tar.gz'));
-        var postResponse =
-            await http.Response.fromStream(await client.send(request));
-
-        var location = postResponse.headers['location'];
-        if (location == null) throw PubHttpException(postResponse);
-        handleJsonSuccess(
-            await client.get(Uri.parse(location), headers: pubApiHeaders));
+        /// 3. Finalize publish
+        var location = uploadResponse.headers['location'];
+        if (location == null) throw PubHttpResponseException(uploadResponse);
+        final finalizeResponse =
+            await retryForHttp('finalizing publish', () async {
+          final request = http.Request('GET', Uri.parse(location));
+          request.attachPubApiHeaders();
+          request.attachMetadataHeaders();
+          return await client.fetch(request);
+        });
+        handleJsonSuccess(finalizeResponse);
       });
     } on AuthenticationException catch (error) {
       var msg = '';
@@ -138,7 +155,7 @@
         msg += '\n${error.serverMessage!}\n';
       }
       dataError(msg + log.red('Authentication failed!'));
-    } on PubHttpException catch (error) {
+    } on PubHttpResponseException catch (error) {
       var url = error.response.request!.url;
       if (url == cloudStorageUrl) {
         // TODO(nweiz): the response may have XML-formatted information about
@@ -189,7 +206,7 @@
           return _publishUsingClient(packageBytes, client);
         });
       }
-    } on PubHttpException catch (error) {
+    } on PubHttpResponseException catch (error) {
       var url = error.response.request!.url;
       if (Uri.parse(url.origin) == Uri.parse(host.origin)) {
         handleJsonError(error.response);
diff --git a/lib/src/command/login.dart b/lib/src/command/login.dart
index 8f428ab..0743845 100644
--- a/lib/src/command/login.dart
+++ b/lib/src/command/login.dart
@@ -7,7 +7,6 @@
 
 import '../command.dart';
 import '../command_runner.dart';
-import '../http.dart';
 import '../log.dart' as log;
 import '../oauth2.dart' as oauth2;
 
@@ -46,9 +45,8 @@
 
   Future<_UserInfo?> _retrieveUserInfo() async {
     return await oauth2.withClient(cache, (client) async {
-      final discovery = await httpClient.get(Uri.https(
-          'accounts.google.com', '/.well-known/openid-configuration'));
-      final userInfoEndpoint = json.decode(discovery.body)['userinfo_endpoint'];
+      final discovery = await oauth2.fetchOidcDiscoveryDocument();
+      final userInfoEndpoint = discovery['userinfo_endpoint'];
       final userInfoRequest = await client.get(Uri.parse(userInfoEndpoint));
       if (userInfoRequest.statusCode != 200) return null;
       try {
diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart
index 39e9c51..90c9d1a 100644
--- a/lib/src/exceptions.dart
+++ b/lib/src/exceptions.dart
@@ -12,6 +12,7 @@
 import 'package:yaml/yaml.dart';
 
 import 'dart.dart';
+import 'http.dart';
 
 /// An exception class for exceptions that are intended to be seen by the user.
 ///
@@ -106,12 +107,9 @@
 }
 
 /// A class for exceptions where a package's checksum could not be validated.
-class PackageIntegrityException extends WrappedException {
-  PackageIntegrityException(
-    String message, {
-    Object? innerError,
-    StackTrace? innerTrace,
-  }) : super(message, innerError, innerTrace);
+class PackageIntegrityException extends PubHttpException {
+  PackageIntegrityException(String message)
+      : super(message, isIntermittent: true);
 }
 
 /// Returns whether [error] is a user-facing error object.
diff --git a/lib/src/http.dart b/lib/src/http.dart
index 6473a8e..ccd5146 100644
--- a/lib/src/http.dart
+++ b/lib/src/http.dart
@@ -9,14 +9,10 @@
 import 'dart:math' as math;
 
 import 'package:http/http.dart' as http;
-import 'package:http/retry.dart';
 import 'package:pool/pool.dart';
-import 'package:stack_trace/stack_trace.dart';
 
 import 'command.dart';
-import 'io.dart';
 import 'log.dart' as log;
-import 'oauth2.dart' as oauth2;
 import 'package.dart';
 import 'sdk.dart';
 import 'source/hosted.dart';
@@ -46,22 +42,6 @@
 
   @override
   Future<http.StreamedResponse> send(http.BaseRequest request) async {
-    if (_shouldAddMetadata(request)) {
-      request.headers['X-Pub-OS'] = Platform.operatingSystem;
-      request.headers['X-Pub-Command'] = PubCommand.command;
-      request.headers['X-Pub-Session-ID'] = _sessionId;
-
-      var environment = Platform.environment['PUB_ENVIRONMENT'];
-      if (environment != null) {
-        request.headers['X-Pub-Environment'] = environment;
-      }
-
-      var type = Zone.current[#_dependencyType];
-      if (type != null && type != DependencyType.none) {
-        request.headers['X-Pub-Reason'] = type.toString();
-      }
-    }
-
     _requestStopwatches[request] = Stopwatch()..start();
     request.headers[HttpHeaders.userAgentHeader] = 'Dart pub ${sdk.version}';
     _logRequest(request);
@@ -73,24 +53,6 @@
     return streamedResponse;
   }
 
-  /// Whether extra metadata headers should be added to [request].
-  bool _shouldAddMetadata(http.BaseRequest request) {
-    if (runningFromTest && Platform.environment.containsKey('PUB_HOSTED_URL')) {
-      if (request.url.origin != Platform.environment['PUB_HOSTED_URL']) {
-        return false;
-      }
-    } else {
-      if (!HostedSource.isPubDevUrl(request.url.toString())) return false;
-    }
-
-    if (Platform.environment.containsKey('CI') &&
-        Platform.environment['CI'] != 'false') {
-      return false;
-    }
-
-    return true;
-  }
-
   /// Logs the fact that [request] was sent, and information about it.
   void _logRequest(http.BaseRequest request) {
     var requestLog = StringBuffer();
@@ -155,130 +117,14 @@
   void close() => _inner.close();
 }
 
-/// The [_PubHttpClient] wrapped by [httpClient].
+/// The [_PubHttpClient] wrapped by [globalHttpClient].
 final _pubClient = _PubHttpClient();
 
-/// A set of all hostnames for which we've printed a message indicating that
-/// we're waiting for them to come back up.
-final _retriedHosts = <String>{};
-
-/// Intercepts all requests and throws exceptions if the response was not
-/// considered successful.
-class _ThrowingClient extends http.BaseClient {
-  final http.Client _inner;
-
-  _ThrowingClient(this._inner);
-
-  @override
-  Future<http.StreamedResponse> send(http.BaseRequest request) async {
-    late http.StreamedResponse streamedResponse;
-    try {
-      streamedResponse = await _inner.send(request);
-    } on SocketException catch (error, stackTraceOrNull) {
-      // Work around issue 23008.
-      var stackTrace = stackTraceOrNull;
-
-      if (error.osError == null) rethrow;
-
-      // Handle error codes known to be related to DNS or SSL issues. While it
-      // is tempting to handle these error codes before retrying, saving time
-      // for the end-user, it is known that DNS lookups can fail intermittently
-      // in some cloud environments. Furthermore, since these error codes are
-      // platform-specific (undocumented) and essentially cargo-culted along
-      // skipping retries may lead to intermittent issues that could be fixed
-      // with a retry. Failing to retry intermittent issues is likely to cause
-      // customers to wrap pub in a retry loop which will not improve the
-      // end-user experience.
-      if (error.osError!.errorCode == 8 ||
-          error.osError!.errorCode == -2 ||
-          error.osError!.errorCode == -5 ||
-          error.osError!.errorCode == 11001 ||
-          error.osError!.errorCode == 11004) {
-        fail('Could not resolve URL "${request.url.origin}".', error,
-            stackTrace);
-      } else if (error.osError!.errorCode == -12276) {
-        fail(
-            'Unable to validate SSL certificate for '
-            '"${request.url.origin}".',
-            error,
-            stackTrace);
-      } else {
-        rethrow;
-      }
-    }
-
-    var status = streamedResponse.statusCode;
-    // 401 responses should be handled by the OAuth2 client. It's very
-    // unlikely that they'll be returned by non-OAuth2 requests. We also want
-    // to pass along 400 responses from the token endpoint.
-    var tokenRequest = streamedResponse.request!.url == oauth2.tokenEndpoint;
-    if (status < 400 || status == 401 || (status == 400 && tokenRequest)) {
-      return streamedResponse;
-    }
-
-    if (status == 406 && request.headers['Accept'] == pubApiHeaders['Accept']) {
-      fail('Pub ${sdk.version} is incompatible with the current version of '
-          '${request.url.host}.\n'
-          'Upgrade pub to the latest version and try again.');
-    }
-
-    if (status == 500 &&
-        (request.url.host == 'pub.dev' ||
-            request.url.host == 'storage.googleapis.com')) {
-      fail('HTTP error 500: Internal Server Error at ${request.url}.\n'
-          'This is likely a transient error. Please try again later.');
-    }
-
-    throw PubHttpException(await http.Response.fromStream(streamedResponse));
-  }
-
-  @override
-  void close() => _inner.close();
-}
-
 /// The HTTP client to use for all HTTP requests.
-final httpClient = _ThrottleClient(
-    16,
-    _ThrowingClient(RetryClient(_pubClient,
-        retries: math.max(
-          1, // Having less than 1 retry is **always** wrong.
-          int.tryParse(Platform.environment['PUB_MAX_HTTP_RETRIES'] ?? '') ?? 7,
-        ),
-        when: (response) =>
-            const [500, 502, 503, 504].contains(response.statusCode),
-        whenError: (error, stackTrace) {
-          if (error is! IOException) return false;
+final globalHttpClient = _pubClient;
 
-          var chain = Chain.forTrace(stackTrace);
-          log.io('HTTP error:\n$error\n\n${chain.terse}');
-          return true;
-        },
-        delay: (retryCount) {
-          if (retryCount < 3) {
-            // Retry quickly a couple times in case of a short transient error.
-            //
-            // Add a random delay to avoid retrying a bunch of parallel requests
-            // all at the same time.
-            return Duration(milliseconds: 500) * math.pow(1.5, retryCount) +
-                Duration(milliseconds: random.nextInt(500));
-          } else {
-            // If the error persists, wait a long time. This works around issues
-            // where an AppEngine instance will go down and need to be rebooted,
-            // which takes about a minute.
-            return Duration(seconds: 30);
-          }
-        },
-        onRetry: (request, response, retryCount) {
-          log.io('Retry #${retryCount + 1} for '
-              '${request.method} ${request.url}...');
-          if (retryCount != 3) return;
-          if (!_retriedHosts.add(request.url.host)) return;
-          log.message(
-              'It looks like ${request.url.host} is having some trouble.\n'
-              'Pub will wait for a while before trying to connect again.');
-        })));
-
-/// The underlying HTTP client wrapped by [httpClient].
+/// The underlying HTTP client wrapped by [globalHttpClient].
+/// This enables the ability to use a mock client in tests.
 http.Client get innerHttpClient => _pubClient._inner;
 set innerHttpClient(http.Client client) => _pubClient._inner = client;
 
@@ -294,6 +140,36 @@
   return runZoned(callback, zoneValues: {#_dependencyType: type});
 }
 
+extension AttachHeaders on http.Request {
+  /// Adds headers required for pub.dev API requests.
+  void attachPubApiHeaders() {
+    headers.addAll(pubApiHeaders);
+  }
+
+  /// Adds request metadata headers about the Pub tool's environment and the
+  /// currently running command if the request URL indicates the destination is
+  /// a Hosted Pub Repository.
+  void attachMetadataHeaders() {
+    if (!HostedSource.shouldSendAdditionalMetadataFor(url)) {
+      return;
+    }
+
+    headers['X-Pub-OS'] = Platform.operatingSystem;
+    headers['X-Pub-Command'] = PubCommand.command;
+    headers['X-Pub-Session-ID'] = _sessionId;
+
+    var environment = Platform.environment['PUB_ENVIRONMENT'];
+    if (environment != null) {
+      headers['X-Pub-Environment'] = environment;
+    }
+
+    var type = Zone.current[#_dependencyType];
+    if (type != null && type != DependencyType.none) {
+      headers['X-Pub-Reason'] = type.toString();
+    }
+  }
+}
+
 /// Handles a successful JSON-formatted response from pub.dev.
 ///
 /// These responses are expected to be of the form `{"success": {"message":
@@ -314,7 +190,12 @@
 /// These responses are expected to be of the form `{"error": {"message": "some
 /// message"}}`. If the format is correct, the message will be raised as an
 /// error; otherwise an [invalidServerResponse] error will be raised.
-void handleJsonError(http.Response response) {
+void handleJsonError(http.BaseResponse response) {
+  if (response is! http.Response) {
+    // Not likely to be a common code path, but necessary.
+    // See https://github.com/dart-lang/pub/pull/3590#discussion_r1012978108
+    fail(log.red('Invalid server response'));
+  }
   var errorMap = parseJsonResponse(response);
   if (errorMap['error'] is! Map ||
       !errorMap['error'].containsKey('message') ||
@@ -345,56 +226,145 @@
 
 /// Exception thrown when an HTTP operation fails.
 class PubHttpException implements Exception {
-  final http.Response response;
+  final String message;
+  final bool isIntermittent;
 
-  const PubHttpException(this.response);
+  PubHttpException(this.message, {this.isIntermittent = false});
 
   @override
-  String toString() => 'HTTP error ${response.statusCode}: '
-      '${response.reasonPhrase}';
+  String toString() {
+    return 'PubHttpException: $message';
+  }
 }
 
-/// A middleware client that throttles the number of concurrent requests.
-///
-/// As long as the number of requests is within the limit, this works just like
-/// a normal client. If a request is made beyond the limit, the underlying HTTP
-/// request won't be sent until other requests have completed.
-class _ThrottleClient extends http.BaseClient {
-  final Pool _pool;
-  final http.Client _inner;
+/// Exception thrown when an HTTP response is not Ok.
+class PubHttpResponseException extends PubHttpException {
+  final http.BaseResponse response;
 
-  /// Creates a new client that allows no more than [maxActiveRequests]
-  /// concurrent requests.
-  ///
-  /// If [inner] is passed, it's used as the inner client for sending HTTP
-  /// requests. It defaults to `new http.Client()`.
-  _ThrottleClient(int maxActiveRequests, this._inner)
-      : _pool = Pool(maxActiveRequests);
+  PubHttpResponseException(this.response,
+      {String message = '', bool isIntermittent = false})
+      : super(message, isIntermittent: isIntermittent);
 
   @override
-  Future<http.StreamedResponse> send(http.BaseRequest request) async {
-    var resource = await _pool.request();
-
-    http.StreamedResponse response;
-    try {
-      response = await _inner.send(request);
-    } catch (_) {
-      resource.release();
-      rethrow;
+  String toString() {
+    var temp = 'PubHttpResponseException: HTTP error ${response.statusCode} '
+        '${response.reasonPhrase}';
+    if (message != '') {
+      temp += ': $message';
     }
+    return temp;
+  }
+}
 
-    final responseController = StreamController<List<int>>(sync: true);
-    unawaited(response.stream.pipe(responseController));
-    unawaited(responseController.done.then((_) => resource.release()));
-    return http.StreamedResponse(responseController.stream, response.statusCode,
-        contentLength: response.contentLength,
-        request: response.request,
-        headers: response.headers,
-        isRedirect: response.isRedirect,
-        persistentConnection: response.persistentConnection,
-        reasonPhrase: response.reasonPhrase);
+/// Whether [e] is one of a few HTTP-related exceptions that subclass
+/// [IOException]. Can be used if your try-catch block contains various
+/// operations in addition to HTTP calls and so a [IOException] instance check
+/// would be too coarse.
+bool isHttpIOException(Object e) {
+  return e is HttpException ||
+      e is TlsException ||
+      e is SocketException ||
+      e is WebSocketException;
+}
+
+/// Program-wide limiter for concurrent network requests.
+final _httpPool = Pool(16);
+
+/// Runs the provided function [fn] and returns the response.
+///
+/// If there is an HTTP-related exception, an intermittent HTTP error response,
+/// or an async timeout, [fn] is run repeatedly until there is a successful
+/// response or at most seven total attempts have been made. If all attempts
+/// fail, the final exception is re-thrown.
+///
+/// Each attempt is run within a [Pool] configured with 16 maximum resources.
+Future<T> retryForHttp<T>(String operation, FutureOr<T> Function() fn) async {
+  return await retry(
+      () async => await _httpPool.withResource(() async => await fn()),
+      retryIf: (e) async =>
+          (e is PubHttpException && e.isIntermittent) ||
+          e is TimeoutException ||
+          isHttpIOException(e),
+      onRetry: (exception, attemptNumber) async =>
+          log.io('Attempt #$attemptNumber for $operation'),
+      maxAttempts: math.max(
+        1, // Having less than 1 attempt doesn't make sense.
+        int.tryParse(Platform.environment['PUB_MAX_HTTP_RETRIES'] ?? '') ?? 7,
+      ));
+}
+
+extension Throwing on http.BaseResponse {
+  /// See https://api.flutter.dev/flutter/dart-io/HttpClientRequest/followRedirects.html
+  static const _redirectStatusCodes = [
+    HttpStatus.movedPermanently,
+    HttpStatus.movedTemporarily,
+    HttpStatus.seeOther,
+    HttpStatus.temporaryRedirect,
+    HttpStatus.permanentRedirect
+  ];
+
+  /// Throws [PubHttpResponseException], calls [fail], or does nothing depending
+  /// on the status code.
+  ///
+  /// If the code is in the 200 range or if its a 300 range redirect code,
+  /// nothing is done. If the code is 408, 429, or in the 500 range,
+  /// [PubHttpResponseException] is thrown with "isIntermittent" set to `true`.
+  /// Otherwise, [PubHttpResponseException] is thrown with "isIntermittent" set
+  /// to `false`.
+  void throwIfNotOk() {
+    if (statusCode >= 200 && statusCode <= 299) {
+      return;
+    } else if (_redirectStatusCodes.contains(statusCode)) {
+      return;
+    } else if (statusCode == HttpStatus.notAcceptable &&
+        request?.headers['Accept'] == pubApiHeaders['Accept']) {
+      fail('Pub ${sdk.version} is incompatible with the current version of '
+          '${request?.url.host}.\n'
+          'Upgrade pub to the latest version and try again.');
+    } else if (statusCode >= 500 ||
+        statusCode == HttpStatus.requestTimeout ||
+        statusCode == HttpStatus.tooManyRequests) {
+      // Throw if the response indicates a server error or an intermittent
+      // client error, but mark it as intermittent so it can be retried.
+      throw PubHttpResponseException(this, isIntermittent: true);
+    } else {
+      // Throw for all other status codes.
+      throw PubHttpResponseException(this);
+    }
+  }
+}
+
+extension RequestSending on http.Client {
+  /// Sends an HTTP request, reads the whole response body, validates the
+  /// response headers, and if validation is successful, and returns it.
+  ///
+  /// The send method on [http.Client], which returns a [http.StreamedResponse],
+  /// is the only method that accepts a request object. This method can be used
+  /// when you need to send a request object but want a regular response object.
+  ///
+  /// If false is passed for [throwIfNotOk], the response will not be validated.
+  /// See [http.BaseResponse.throwIfNotOk] extension for validation details.
+  Future<http.Response> fetch(http.BaseRequest request,
+      {bool throwIfNotOk = true}) async {
+    final streamedResponse = await send(request);
+    final response = await http.Response.fromStream(streamedResponse);
+    if (throwIfNotOk) {
+      response.throwIfNotOk();
+    }
+    return response;
   }
 
-  @override
-  void close() => _inner.close();
+  /// Sends an HTTP request, validates the response headers, and if validation
+  /// is successful, returns a [http.StreamedResponse].
+  ///
+  /// If false is passed for [throwIfNotOk], the response will not be validated.
+  /// See [http.BaseResponse.throwIfNotOk] extension for validation details.
+  Future<http.StreamedResponse> fetchAsStream(http.BaseRequest request,
+      {bool throwIfNotOk = true}) async {
+    final streamedResponse = await send(request);
+    if (throwIfNotOk) {
+      streamedResponse.throwIfNotOk();
+    }
+    return streamedResponse;
+  }
 }
diff --git a/lib/src/io.dart b/lib/src/io.dart
index 2b5d0cd..6f99b5b 100644
--- a/lib/src/io.dart
+++ b/lib/src/io.dart
@@ -15,13 +15,14 @@
 import 'package:meta/meta.dart';
 import 'package:path/path.dart' as path;
 import 'package:pool/pool.dart';
+// ignore: prefer_relative_imports
+import 'package:pub/src/third_party/tar/lib/tar.dart';
 import 'package:stack_trace/stack_trace.dart';
 
 import 'error_group.dart';
 import 'exceptions.dart';
 import 'exit_codes.dart' as exit_codes;
 import 'log.dart' as log;
-import 'third_party/tar/tar.dart';
 import 'utils.dart';
 
 export 'package:http/http.dart' show ByteStream;
diff --git a/lib/src/oauth2.dart b/lib/src/oauth2.dart
index 2159847..4715014 100644
--- a/lib/src/oauth2.dart
+++ b/lib/src/oauth2.dart
@@ -6,8 +6,11 @@
 import 'dart:io';
 
 import 'package:collection/collection.dart' show IterableExtension;
-import 'package:oauth2/oauth2.dart';
+import 'package:http/http.dart' as http;
+import 'package:http/retry.dart';
 import 'package:path/path.dart' as path;
+// ignore: prefer_relative_imports
+import 'package:pub/src/third_party/oauth2/lib/oauth2.dart';
 import 'package:shelf/shelf.dart' as shelf;
 import 'package:shelf/shelf_io.dart' as shelf_io;
 
@@ -17,6 +20,13 @@
 import 'system_cache.dart';
 import 'utils.dart';
 
+/// The global HTTP client with basic retries. Used instead of retryForHttp for
+/// OAuth calls because the OAuth2 package requires a client to be passed. While
+/// the retry logic is more basic, this is fine for the publishing process.
+final _retryHttpClient = RetryClient(globalHttpClient,
+    when: (response) => response.statusCode >= 500,
+    whenError: (e, _) => isHttpIOException(e));
+
 /// The pub client's OAuth2 identifier.
 const _identifier = '818368855108-8grd2eg9tj9f38os6f1urbcvsq399u8n.apps.'
     'googleusercontent.com';
@@ -26,6 +36,12 @@
 /// This isn't actually meant to be kept a secret.
 const _secret = 'SWeqj8seoJW0w7_CpEPFLX0K';
 
+/// The URL from which the pub client will retrieve Google's OIDC endpoint URIs.
+///
+/// [Google OpenID Connect documentation]: https://developers.google.com/identity/openid-connect/openid-connect#discovery
+final _oidcDiscoveryDocumentEndpoint =
+    Uri.https('accounts.google.com', '/.well-known/openid-configuration');
+
 /// The URL to which the user will be directed to authorize the pub client to
 /// get an OAuth2 access token.
 ///
@@ -142,7 +158,7 @@
       secret: _secret,
       // Google's OAuth2 API doesn't support basic auth.
       basicAuth: false,
-      httpClient: httpClient);
+      httpClient: _retryHttpClient);
   _saveCredentials(cache, client.credentials);
   return client;
 }
@@ -221,7 +237,7 @@
           secret: _secret,
           // Google's OAuth2 API doesn't support basic auth.
           basicAuth: false,
-          httpClient: httpClient);
+          httpClient: _retryHttpClient);
 
   // Spin up a one-shot HTTP server to receive the authorization code from the
   // Google OAuth2 server via redirect. This server will close itself as soon as
@@ -258,3 +274,16 @@
   log.message('Successfully authorized.\n');
   return client;
 }
+
+/// Fetches Google's OpenID Connect Discovery document and parses the JSON
+/// response body into a [Map].
+///
+/// See https://developers.google.com/identity/openid-connect/openid-connect#discovery
+Future<Map> fetchOidcDiscoveryDocument() async {
+  final discoveryResponse = await retryForHttp(
+      'fetching Google\'s OpenID Connect Discovery document', () async {
+    final request = http.Request('GET', _oidcDiscoveryDocumentEndpoint);
+    return await globalHttpClient.fetch(request);
+  });
+  return parseJsonResponse(discoveryResponse);
+}
diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart
index 4e8a939..1cb05d6 100644
--- a/lib/src/source/hosted.dart
+++ b/lib/src/source/hosted.dart
@@ -5,7 +5,7 @@
 import 'dart:async';
 import 'dart:convert';
 import 'dart:io' as io;
-import 'dart:math' as math;
+import 'dart:io';
 import 'dart:typed_data';
 
 import 'package:collection/collection.dart'
@@ -129,6 +129,11 @@
 
   static bool isPubDevUrl(String url) {
     final origin = Uri.parse(url).origin;
+    // Allow the defaultHostedUrl to be overriden when running from tests
+    if (runningFromTest &&
+        io.Platform.environment['_PUB_TEST_DEFAULT_HOSTED_URL'] != null) {
+      return origin == io.Platform.environment['_PUB_TEST_DEFAULT_HOSTED_URL'];
+    }
     return origin == pubDevUrl || origin == pubDartlangUrl;
   }
 
@@ -167,6 +172,25 @@
     }
   }();
 
+  /// Whether extra metadata headers should be sent for HTTP requests to a given
+  /// [url].
+  static bool shouldSendAdditionalMetadataFor(Uri url) {
+    if (runningFromTest && Platform.environment.containsKey('PUB_HOSTED_URL')) {
+      if (url.origin != Platform.environment['PUB_HOSTED_URL']) {
+        return false;
+      }
+    } else {
+      if (!HostedSource.isPubDevUrl(url.toString())) return false;
+    }
+
+    if (Platform.environment.containsKey('CI') &&
+        Platform.environment['CI'] != 'false') {
+      return false;
+    }
+
+    return true;
+  }
+
   /// Returns a reference to a hosted package named [name].
   ///
   /// If [url] is passed, it's the URL of the pub server from which the package
@@ -240,8 +264,6 @@
     );
   }
 
-  HostedDescription _asDescription(desc) => desc as HostedDescription;
-
   /// Parses the description for a package.
   ///
   /// If the package parses correctly, this returns a (name, url) pair. If not,
@@ -365,6 +387,7 @@
     if (description is! HostedDescription) {
       throw ArgumentError('Wrong source');
     }
+    final packageName = description.packageName;
     final hostedUrl = description.url;
     final url = _listVersionsUrl(ref);
     log.io('Get versions from $url.');
@@ -374,12 +397,18 @@
     final List<_VersionInfo> result;
     try {
       // TODO(sigurdm): Implement cancellation of requests. This probably
-      // requires resolution of: https://github.com/dart-lang/sdk/issues/22265.
-      bodyText = await withAuthenticatedClient(
-        cache,
-        Uri.parse(hostedUrl),
-        (client) => client.read(url, headers: pubApiHeaders),
-      );
+      // requires resolution of: https://github.com/dart-lang/http/issues/424.
+      bodyText = await withAuthenticatedClient(cache, Uri.parse(hostedUrl),
+          (client) async {
+        return await retryForHttp(
+            'fetching versions for "$packageName" from "$url"', () async {
+          final request = http.Request('GET', url);
+          request.attachPubApiHeaders();
+          request.attachMetadataHeaders();
+          final response = await client.fetch(request);
+          return response.body;
+        });
+      });
       final decoded = jsonDecode(bodyText);
       if (decoded is! Map<String, dynamic>) {
         throw FormatException('version listing must be a mapping');
@@ -387,7 +416,6 @@
       body = decoded;
       result = _versionInfoFromPackageListing(body, ref, url, cache);
     } on Exception catch (error, stackTrace) {
-      final packageName = _asDescription(ref.description).packageName;
       _throwFriendlyError(error, stackTrace, packageName, hostedUrl);
     }
 
@@ -994,7 +1022,7 @@
         versions.firstWhereOrNull((i) => i.version == id.version);
     final packageName = id.name;
     final version = id.version;
-    late Uint8List contentHash;
+    late final Uint8List contentHash;
     if (versionInfo == null) {
       throw PackageNotFoundException(
           'Package $packageName has no version $version');
@@ -1032,13 +1060,8 @@
 See $contentHashesDocumentationUrl.
 ''');
         }
-        final path = hashPath(id, cache);
-        ensureDir(p.dirname(path));
-        writeTextFile(
-          path,
-          hexEncode(actualHash.bytes),
-        );
         contentHash = Uint8List.fromList(actualHash.bytes);
+        writeHash(id, cache, contentHash);
       }
 
       // It is important that we do not compare against id.description.sha256,
@@ -1047,48 +1070,43 @@
       // download.
       final expectedSha256 = versionInfo.archiveSha256;
 
-      // The client from `withAuthenticatedClient` will retry HTTP requests.
-      // This wrapper is one layer up and will retry checksum validation errors.
-      await retry(
-        // Attempt to download archive and validate its checksum.
-        () async {
+      await withAuthenticatedClient(cache, Uri.parse(description.url),
+          (client) async {
+        // In addition to HTTP errors, this will retry crc32c/sha256 errors as
+        // well because [PackageIntegrityException] subclasses
+        // [PubHttpException].
+        await retryForHttp('downloading "$archiveUrl"', () async {
           final request = http.Request('GET', archiveUrl);
-          final response = await withAuthenticatedClient(cache,
-              Uri.parse(description.url), (client) => client.send(request));
-          final expectedCrc32Checksum =
-              _parseCrc32c(response.headers, fileName);
+          request.attachMetadataHeaders();
+          final response = await client.fetchAsStream(request);
 
           Stream<List<int>> stream = response.stream;
-          if (expectedCrc32Checksum != null) {
-            stream = _validateStreamCrc32Checksum(
-                response.stream, expectedCrc32Checksum, id, archiveUrl);
+          final expectedCrc32c = _parseCrc32c(response.headers, fileName);
+          if (expectedCrc32c != null) {
+            stream = _validateCrc32c(
+                response.stream, expectedCrc32c, id, archiveUrl);
           }
           stream = validateSha256(
               stream, (expectedSha256 == null) ? null : Digest(expectedSha256));
+
           // We download the archive to disk instead of streaming it directly
           // into the tar unpacking. This simplifies stream handling.
           // Package:tar cancels the stream when it reaches end-of-archive, and
           // cancelling a http stream makes it not reusable.
           // There are ways around this, and we might revisit this later.
           await createFileFromStream(stream, archivePath);
-        },
-        // Retry if the checksum response header was malformed or the actual
-        // checksum did not match the expected checksum.
-        retryIf: (e) => e is PackageIntegrityException,
-        onRetry: (e, retryCount) => log
-            .io('Retry #${retryCount + 1} because of checksum error with GET '
-                '$archiveUrl...'),
-        maxAttempts: math.max(
-          1, // Having less than 1 attempt doesn't make sense.
-          int.tryParse(io.Platform.environment['PUB_MAX_HTTP_RETRIES'] ?? '') ??
-              7,
-        ),
-      );
+        });
+      });
 
       var tempDir = cache.createTempDir();
-      await extractTarGz(readBinaryFileAsStream(archivePath), tempDir);
+      try {
+        await extractTarGz(readBinaryFileAsStream(archivePath), tempDir);
 
-      ensureDir(p.dirname(destPath));
+        ensureDir(p.dirname(destPath));
+      } catch (e) {
+        deleteEntry(tempDir);
+        rethrow;
+      }
       // Now that the get has succeeded, move it to the real location in the
       // cache.
       //
@@ -1100,6 +1118,84 @@
     });
   }
 
+  /// Writes the contenthash for [id] in the cache.
+  void writeHash(PackageId id, SystemCache cache, List<int> bytes) {
+    final path = hashPath(id, cache);
+    ensureDir(p.dirname(path));
+    writeTextFile(
+      path,
+      hexEncode(bytes),
+    );
+  }
+
+  /// Installs a tar.gz file in [archivePath] as if it was downloaded from a
+  /// package repository.
+  ///
+  /// The name, version and repository are decided from the pubspec.yaml that
+  /// must be present in the archive.
+  Future<PackageId> preloadPackage(
+      String archivePath, SystemCache cache) async {
+    // Extract to a temp-folder and do atomic rename to preserve the integrity
+    // of the cache.
+    late final Uint8List contentHash;
+
+    var tempDir = cache.createTempDir();
+    final PackageId id;
+    try {
+      try {
+        // We read the file twice, once to compute the hash, and once to extract
+        // the archive.
+        //
+        // It would be desirable to read the file only once, but the tar
+        // extraction closes the stream early making things tricky to get right.
+        contentHash = Uint8List.fromList(
+            (await sha256.bind(readBinaryFileAsStream(archivePath)).first)
+                .bytes);
+        await extractTarGz(readBinaryFileAsStream(archivePath), tempDir);
+      } on FormatException catch (e) {
+        dataError('Failed to extract `$archivePath`: ${e.message}.');
+      }
+      if (!fileExists(p.join(tempDir, 'pubspec.yaml'))) {
+        fail(
+            'Found no `pubspec.yaml` in $archivePath. Is it a valid pub package archive?');
+      }
+      final Pubspec pubspec;
+      try {
+        pubspec = Pubspec.load(tempDir, cache.sources);
+        final errors = pubspec.allErrors;
+        if (errors.isNotEmpty) {
+          throw errors.first;
+        }
+      } on Exception catch (e) {
+        fail('Failed to load `pubspec.yaml` from `$archivePath`: $e.');
+      }
+      // Reconstruct the PackageId from the extracted pubspec.yaml.
+      id = PackageId(
+        pubspec.name,
+        pubspec.version,
+        ResolvedHostedDescription(
+          HostedDescription(
+            pubspec.name,
+            validateAndNormalizeHostedUrl(cache.hosted.defaultUrl).toString(),
+          ),
+          sha256: contentHash,
+        ),
+      );
+    } catch (e) {
+      deleteEntry(tempDir);
+      rethrow;
+    }
+    final packageDir = getDirectoryInCache(id, cache);
+    if (dirExists(packageDir)) {
+      log.fine(
+          'Cache entry for ${id.name}-${id.version} already exists. Replacing.');
+      deleteEntry(packageDir);
+    }
+    tryRenameDir(tempDir, packageDir);
+    writeHash(id, cache, contentHash);
+    return id;
+  }
+
   /// When an error occurs trying to read something about [package] from [hostedUrl],
   /// this tries to translate into a more user friendly error message.
   ///
@@ -1110,7 +1206,7 @@
     String package,
     String hostedUrl,
   ) {
-    if (error is PubHttpException) {
+    if (error is PubHttpResponseException) {
       if (error.response.statusCode == 404) {
         throw PackageNotFoundException(
             'could not find package $package at $hostedUrl',
@@ -1378,7 +1474,7 @@
 /// the one present in the checksum response header.
 ///
 /// Throws [PackageIntegrityException] if there is a checksum mismatch.
-Stream<List<int>> _validateStreamCrc32Checksum(Stream<List<int>> stream,
+Stream<List<int>> _validateCrc32c(Stream<List<int>> stream,
     int expectedChecksum, PackageId id, Uri archiveUrl) async* {
   final crc32c = Crc32c();
 
@@ -1436,11 +1532,10 @@
 
         return ByteData.view(bytes.buffer).getUint32(0);
       } on FormatException catch (e, s) {
+        log.exception(e, s);
         throw PackageIntegrityException(
             'Package archive "$fileName" has a malformed CRC32C checksum in '
-            'its response headers',
-            innerError: e,
-            innerTrace: s);
+            'its response headers');
       }
     }
   }
diff --git a/lib/src/third_party/oauth2/CHANGELOG.md b/lib/src/third_party/oauth2/CHANGELOG.md
new file mode 100644
index 0000000..0c0deb2
--- /dev/null
+++ b/lib/src/third_party/oauth2/CHANGELOG.md
@@ -0,0 +1,123 @@
+# 2.0.1
+
+* Handle `expires_in` when encoded as string.
+* Populate the pubspec `repository` field.
+* Increase the minimum Dart SDK to `2.17.0`.
+
+# 2.0.0
+
+* Migrate to null safety.
+
+# 1.6.3
+
+* Added optional `codeVerifier` parameter to `AuthorizationCodeGrant` constructor.
+
+# 1.6.1
+
+* Added fix to make sure that credentials are only refreshed once when multiple calls are made.
+
+# 1.6.0
+
+* Added PKCE support to `AuthorizationCodeGrant`.
+
+# 1.5.0
+
+* Added support for `clientCredentialsGrant`.
+
+# 1.4.0
+
+* OpenID's id_token treated.
+
+# 1.3.0
+
+* Added `onCredentialsRefreshed` option when creating `Client` objects.
+
+# 1.2.3
+
+* Support the latest `package:http` release.
+
+# 1.2.2
+
+* Allow the stable 2.0 SDK.
+
+# 1.2.1
+
+* Updated SDK version to 2.0.0-dev.17.0
+
+# 1.2.0
+
+* Add a `getParameter()` parameter to `new AuthorizationCodeGrant()`, `new
+  Credentials()`, and `resourceOwnerPasswordGrant()`. This controls how the
+  authorization server's response is parsed for servers that don't provide the
+  standard JSON response.
+
+# 1.1.1
+
+* `resourceOwnerPasswordGrant()` now properly uses its HTTP client for requests
+  made by the OAuth2 client it returns.
+
+# 1.1.0
+
+* Add a `delimiter` parameter to `new AuthorizationCodeGrant()`, `new
+  Credentials()`, and `resourceOwnerPasswordGrant()`. This controls the
+  delimiter between scopes, which some authorization servers require to be
+  different values than the specified `' '`.
+
+# 1.0.2
+
+* Fix all strong-mode warnings.
+
+* Support `crypto` 1.0.0.
+
+* Support `http_parser` 3.0.0.
+
+# 1.0.1
+
+* Support `http_parser` 2.0.0.
+
+# 1.0.0
+
+## Breaking changes
+
+* Requests that use client authentication, such as the
+  `AuthorizationCodeGrant`'s access token request and `Credentials`' refresh
+  request, now use HTTP Basic authentication by default. This form of
+  authentication is strongly recommended by the OAuth 2.0 spec. The new
+  `basicAuth` parameter may be set to `false` to force form-based authentication
+  for servers that require it.
+
+* `new AuthorizationCodeGrant()` now takes `secret` as an optional named
+  argument rather than a required argument. This matches the OAuth 2.0 spec,
+  which says that a client secret is only required for confidential clients.
+
+* `new Client()` and `Credentials.refresh()` now take both `identifier` and
+  `secret` as optional named arguments rather than required arguments. This
+  matches the OAuth 2.0 spec, which says that the server may choose not to
+  require client authentication for some flows.
+
+* `new Credentials()` now takes named arguments rather than optional positional
+  arguments.
+
+## Non-breaking changes
+
+* Added a `resourceOwnerPasswordGrant` method.
+
+* The `scopes` argument to `AuthorizationCodeGrant.getAuthorizationUrl()` and
+  `new Credentials()` and the `newScopes` argument to `Credentials.refresh` now
+  take an `Iterable` rather than just a `List`.
+
+* The `scopes` argument to `AuthorizationCodeGrant.getAuthorizationUrl()` now
+  defaults to `null` rather than `const []`.
+
+# 0.9.3
+
+* Update the `http` dependency.
+
+* Since `http` 0.11.0 now works in non-`dart:io` contexts, `oauth2` does as
+  well.
+
+# 0.9.2
+
+* Expand the dependency on the HTTP package to include 0.10.x.
+
+* Add a README file.
diff --git a/lib/src/third_party/oauth2/LICENSE b/lib/src/third_party/oauth2/LICENSE
new file mode 100644
index 0000000..162572a
--- /dev/null
+++ b/lib/src/third_party/oauth2/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2014, the Dart project authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of Google LLC nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/lib/src/third_party/oauth2/README.md b/lib/src/third_party/oauth2/README.md
new file mode 100644
index 0000000..196b9f7
--- /dev/null
+++ b/lib/src/third_party/oauth2/README.md
@@ -0,0 +1,260 @@
+[![Dart CI](https://github.com/dart-lang/oauth2/actions/workflows/test-package.yml/badge.svg)](https://github.com/dart-lang/oauth2/actions/workflows/test-package.yml)
+[![pub package](https://img.shields.io/pub/v/oauth2.svg)](https://pub.dev/packages/oauth2)
+[![package publisher](https://img.shields.io/pub/publisher/oauth2.svg)](https://pub.dev/packages/oauth2/publisher)
+
+A client library for authenticating with a remote service via OAuth2 on behalf
+of a user, and making authorized HTTP requests with the user's OAuth2
+credentials.
+
+## About OAuth2
+
+OAuth2 allows a client (the program using this library) to access and manipulate
+a resource that's owned by a resource owner (the end user) and lives on a remote
+server. The client directs the resource owner to an authorization server
+(usually but not always the same as the server that hosts the resource), where
+the resource owner tells the authorization server to give the client an access
+token. This token serves as proof that the client has permission to access
+resources on behalf of the resource owner.
+
+OAuth2 provides several different methods for the client to obtain
+authorization. At the time of writing, this library only supports the
+[Authorization Code Grant][authorizationCodeGrantSection],
+[Client Credentials Grant][clientCredentialsGrantSection] and
+[Resource Owner Password Grant][resourceOwnerPasswordGrantSection] flows, but
+more may be added in the future.
+
+## Authorization Code Grant
+
+**Resources:** [Class summary][authorizationCodeGrantMethod],
+[OAuth documentation][authorizationCodeGrantDocs]
+
+```dart
+import 'dart:io';
+
+import 'package:oauth2/oauth2.dart' as oauth2;
+
+// These URLs are endpoints that are provided by the authorization
+// server. They're usually included in the server's documentation of its
+// OAuth2 API.
+final authorizationEndpoint =
+    Uri.parse('http://example.com/oauth2/authorization');
+final tokenEndpoint = Uri.parse('http://example.com/oauth2/token');
+
+// The authorization server will issue each client a separate client
+// identifier and secret, which allows the server to tell which client
+// is accessing it. Some servers may also have an anonymous
+// identifier/secret pair that any client may use.
+//
+// Note that clients whose source code or binary executable is readily
+// available may not be able to make sure the client secret is kept a
+// secret. This is fine; OAuth2 servers generally won't rely on knowing
+// with certainty that a client is who it claims to be.
+final identifier = 'my client identifier';
+final secret = 'my client secret';
+
+// This is a URL on your application's server. The authorization server
+// will redirect the resource owner here once they've authorized the
+// client. The redirection will include the authorization code in the
+// query parameters.
+final redirectUrl = Uri.parse('http://my-site.com/oauth2-redirect');
+
+/// A file in which the users credentials are stored persistently. If the server
+/// issues a refresh token allowing the client to refresh outdated credentials,
+/// these may be valid indefinitely, meaning the user never has to
+/// re-authenticate.
+final credentialsFile = File('~/.myapp/credentials.json');
+
+/// Either load an OAuth2 client from saved credentials or authenticate a new
+/// one.
+Future<oauth2.Client> createClient() async {
+  var exists = await credentialsFile.exists();
+
+  // If the OAuth2 credentials have already been saved from a previous run, we
+  // just want to reload them.
+  if (exists) {
+    var credentials =
+        oauth2.Credentials.fromJson(await credentialsFile.readAsString());
+    return oauth2.Client(credentials, identifier: identifier, secret: secret);
+  }
+
+  // If we don't have OAuth2 credentials yet, we need to get the resource owner
+  // to authorize us. We're assuming here that we're a command-line application.
+  var grant = oauth2.AuthorizationCodeGrant(
+      identifier, authorizationEndpoint, tokenEndpoint,
+      secret: secret);
+
+  // A URL on the authorization server (authorizationEndpoint with some additional
+  // query parameters). Scopes and state can optionally be passed into this method.
+  var authorizationUrl = grant.getAuthorizationUrl(redirectUrl);
+
+  // Redirect the resource owner to the authorization URL. Once the resource
+  // owner has authorized, they'll be redirected to `redirectUrl` with an
+  // authorization code. The `redirect` should cause the browser to redirect to
+  // another URL which should also have a listener.
+  //
+  // `redirect` and `listen` are not shown implemented here. See below for the
+  // details.
+  await redirect(authorizationUrl);
+  var responseUrl = await listen(redirectUrl);
+
+  // Once the user is redirected to `redirectUrl`, pass the query parameters to
+  // the AuthorizationCodeGrant. It will validate them and extract the
+  // authorization code to create a new Client.
+  return await grant.handleAuthorizationResponse(responseUrl.queryParameters);
+}
+
+void main() async {
+  var client = await createClient();
+
+  // Once you have a Client, you can use it just like any other HTTP client.
+  print(await client.read('http://example.com/protected-resources.txt'));
+
+  // Once we're done with the client, save the credentials file. This ensures
+  // that if the credentials were automatically refreshed while using the
+  // client, the new credentials are available for the next run of the
+  // program.
+  await credentialsFile.writeAsString(client.credentials.toJson());
+}
+```
+
+<details>
+  <summary>Click here to learn how to implement `redirect` and `listen`.</summary>
+
+--------------------------------------------------------------------------------
+
+There is not a universal example for implementing `redirect` and `listen`,
+because different options exist for each platform.
+
+For Flutter apps, there's two popular approaches:
+
+1.  Launch a browser using [url_launcher][] and listen for a redirect using
+    [uni_links][].
+
+    ```dart
+      if (await canLaunch(authorizationUrl.toString())) {
+        await launch(authorizationUrl.toString()); }
+
+      // ------- 8< -------
+
+      final linksStream = getLinksStream().listen((Uri uri) async {
+       if (uri.toString().startsWith(redirectUrl)) {
+         responseUrl = uri;
+       }
+     });
+    ```
+
+1.  Launch a WebView inside the app and listen for a redirect using
+    [webview_flutter][].
+
+    ```dart
+      WebView(
+        javascriptMode: JavascriptMode.unrestricted,
+        initialUrl: authorizationUrl.toString(),
+        navigationDelegate: (navReq) {
+          if (navReq.url.startsWith(redirectUrl)) {
+            responseUrl = Uri.parse(navReq.url);
+            return NavigationDecision.prevent;
+          }
+          return NavigationDecision.navigate;
+        },
+        // ------- 8< -------
+      );
+    ```
+
+For Dart apps, the best approach depends on the available options for accessing
+a browser. In general, you'll need to launch the authorization URL through the
+client's browser and listen for the redirect URL.
+</details>
+
+## Client Credentials Grant
+
+**Resources:** [Method summary][clientCredentialsGrantMethod],
+[OAuth documentation][clientCredentialsGrantDocs]
+
+```dart
+// This URL is an endpoint that's provided by the authorization server. It's
+// usually included in the server's documentation of its OAuth2 API.
+final authorizationEndpoint =
+    Uri.parse('http://example.com/oauth2/authorization');
+
+// The OAuth2 specification expects a client's identifier and secret
+// to be sent when using the client credentials grant.
+//
+// Because the client credentials grant is not inherently associated with a user,
+// it is up to the server in question whether the returned token allows limited
+// API access.
+//
+// Either way, you must provide both a client identifier and a client secret:
+final identifier = 'my client identifier';
+final secret = 'my client secret';
+
+// Calling the top-level `clientCredentialsGrant` function will return a
+// [Client] instead.
+var client = await oauth2.clientCredentialsGrant(
+    authorizationEndpoint, identifier, secret);
+
+// With an authenticated client, you can make requests, and the `Bearer` token
+// returned by the server during the client credentials grant will be attached
+// to any request you make.
+var response =
+    await client.read('https://example.com/api/some_resource.json');
+
+// You can save the client's credentials, which consists of an access token, and
+// potentially a refresh token and expiry date, to a file. This way, subsequent runs
+// do not need to reauthenticate, and you can avoid saving the client identifier and
+// secret.
+await credentialsFile.writeAsString(client.credentials.toJson());
+```
+
+## Resource Owner Password Grant
+
+**Resources:** [Method summary][resourceOwnerPasswordGrantMethod],
+[OAuth documentation][resourceOwnerPasswordGrantDocs]
+
+```dart
+// This URL is an endpoint that's provided by the authorization server. It's
+// usually included in the server's documentation of its OAuth2 API.
+final authorizationEndpoint =
+    Uri.parse('http://example.com/oauth2/authorization');
+
+// The user should supply their own username and password.
+final username = 'example user';
+final password = 'example password';
+
+// The authorization server may issue each client a separate client
+// identifier and secret, which allows the server to tell which client
+// is accessing it. Some servers may also have an anonymous
+// identifier/secret pair that any client may use.
+//
+// Some servers don't require the client to authenticate itself, in which case
+// these should be omitted.
+final identifier = 'my client identifier';
+final secret = 'my client secret';
+
+// Make a request to the authorization endpoint that will produce the fully
+// authenticated Client.
+var client = await oauth2.resourceOwnerPasswordGrant(
+    authorizationEndpoint, username, password,
+    identifier: identifier, secret: secret);
+
+// Once you have the client, you can use it just like any other HTTP client.
+var result = await client.read('http://example.com/protected-resources.txt');
+
+// Once we're done with the client, save the credentials file. This will allow
+// us to re-use the credentials and avoid storing the username and password
+// directly.
+File('~/.myapp/credentials.json').writeAsString(client.credentials.toJson());
+```
+
+[authorizationCodeGrantDocs]: https://oauth.net/2/grant-types/authorization-code/
+[authorizationCodeGrantMethod]: https://pub.dev/documentation/oauth2/latest/oauth2/AuthorizationCodeGrant-class.html
+[authorizationCodeGrantSection]: #authorization-code-grant
+[clientCredentialsGrantDocs]: https://oauth.net/2/grant-types/client-credentials/
+[clientCredentialsGrantMethod]: https://pub.dev/documentation/oauth2/latest/oauth2/clientCredentialsGrant.html
+[clientCredentialsGrantSection]: #client-credentials-grant
+[resourceOwnerPasswordGrantDocs]: https://oauth.net/2/grant-types/password/
+[resourceOwnerPasswordGrantMethod]: https://pub.dev/documentation/oauth2/latest/oauth2/resourceOwnerPasswordGrant.html
+[resourceOwnerPasswordGrantSection]: #resource-owner-password-grant
+[uni_links]: https://pub.dev/packages/uni_links
+[url_launcher]: https://pub.dev/packages/url_launcher
+[webview_flutter]: https://pub.dev/packages/webview_flutter
diff --git a/lib/src/third_party/oauth2/analysis_options.yaml b/lib/src/third_party/oauth2/analysis_options.yaml
new file mode 100644
index 0000000..c8bc59c
--- /dev/null
+++ b/lib/src/third_party/oauth2/analysis_options.yaml
@@ -0,0 +1,40 @@
+include: package:lints/recommended.yaml
+
+analyzer:
+  language:
+    strict-casts: true
+    strict-raw-types: true
+
+linter:
+  rules:
+    - always_declare_return_types
+    - avoid_catching_errors
+    - avoid_dynamic_calls
+    - avoid_private_typedef_functions
+    - avoid_unused_constructor_parameters
+    - avoid_void_async
+    - cancel_subscriptions
+    - directives_ordering
+    - literal_only_boolean_expressions
+    - no_adjacent_strings_in_list
+    - no_runtimeType_toString
+    - omit_local_variable_types
+    - only_throw_errors
+    - package_api_docs
+    - prefer_asserts_in_initializer_lists
+    - prefer_const_constructors
+    - prefer_const_declarations
+    - prefer_relative_imports
+    - prefer_single_quotes
+    - sort_pub_dependencies
+    - test_types_in_equals
+    - throw_in_finally
+    - type_annotate_public_apis
+    - unawaited_futures
+    - unnecessary_await_in_return
+    - unnecessary_lambdas
+    - unnecessary_parenthesis
+    - unnecessary_statements
+    - use_is_even_rather_than_modulo
+    - use_string_buffers
+    - use_super_parameters
diff --git a/lib/src/third_party/oauth2/lib/oauth2.dart b/lib/src/third_party/oauth2/lib/oauth2.dart
new file mode 100644
index 0000000..45efc5c
--- /dev/null
+++ b/lib/src/third_party/oauth2/lib/oauth2.dart
@@ -0,0 +1,11 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+export 'src/authorization_code_grant.dart';
+export 'src/authorization_exception.dart';
+export 'src/client.dart';
+export 'src/client_credentials_grant.dart';
+export 'src/credentials.dart';
+export 'src/expiration_exception.dart';
+export 'src/resource_owner_password_grant.dart';
diff --git a/lib/src/third_party/oauth2/lib/src/authorization_code_grant.dart b/lib/src/third_party/oauth2/lib/src/authorization_code_grant.dart
new file mode 100644
index 0000000..fac56ba
--- /dev/null
+++ b/lib/src/third_party/oauth2/lib/src/authorization_code_grant.dart
@@ -0,0 +1,371 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:math';
+
+import 'package:crypto/crypto.dart';
+import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
+
+import 'authorization_exception.dart';
+import 'client.dart';
+import 'credentials.dart';
+import 'handle_access_token_response.dart';
+import 'parameters.dart';
+import 'utils.dart';
+
+/// A class for obtaining credentials via an [authorization code grant][].
+///
+/// This method of authorization involves sending the resource owner to the
+/// authorization server where they will authorize the client. They're then
+/// redirected back to your server, along with an authorization code. This is
+/// used to obtain [Credentials] and create a fully-authorized [Client].
+///
+/// To use this class, you must first call [getAuthorizationUrl] to get the URL
+/// to which to redirect the resource owner. Then once they've been redirected
+/// back to your application, call [handleAuthorizationResponse] or
+/// [handleAuthorizationCode] to process the authorization server's response and
+/// construct a [Client].
+///
+/// [authorization code grant]: http://tools.ietf.org/html/draft-ietf-oauth-v2-31#section-4.1
+class AuthorizationCodeGrant {
+  /// The function used to parse parameters from a host's response.
+  final GetParameters _getParameters;
+
+  /// The client identifier for this client.
+  ///
+  /// The authorization server will issue each client a separate client
+  /// identifier and secret, which allows the server to tell which client is
+  /// accessing it. Some servers may also have an anonymous identifier/secret
+  /// pair that any client may use.
+  ///
+  /// This is usually global to the program using this library.
+  final String identifier;
+
+  /// The client secret for this client.
+  ///
+  /// The authorization server will issue each client a separate client
+  /// identifier and secret, which allows the server to tell which client is
+  /// accessing it. Some servers may also have an anonymous identifier/secret
+  /// pair that any client may use.
+  ///
+  /// This is usually global to the program using this library.
+  ///
+  /// Note that clients whose source code or binary executable is readily
+  /// available may not be able to make sure the client secret is kept a secret.
+  /// This is fine; OAuth2 servers generally won't rely on knowing with
+  /// certainty that a client is who it claims to be.
+  final String? secret;
+
+  /// A URL provided by the authorization server that serves as the base for the
+  /// URL that the resource owner will be redirected to to authorize this
+  /// client.
+  ///
+  /// This will usually be listed in the authorization server's OAuth2 API
+  /// documentation.
+  final Uri authorizationEndpoint;
+
+  /// A URL provided by the authorization server that this library uses to
+  /// obtain long-lasting credentials.
+  ///
+  /// This will usually be listed in the authorization server's OAuth2 API
+  /// documentation.
+  final Uri tokenEndpoint;
+
+  /// Callback to be invoked whenever the credentials are refreshed.
+  ///
+  /// This will be passed as-is to the constructed [Client].
+  final CredentialsRefreshedCallback? _onCredentialsRefreshed;
+
+  /// Whether to use HTTP Basic authentication for authorizing the client.
+  final bool _basicAuth;
+
+  /// A [String] used to separate scopes; defaults to `" "`.
+  final String _delimiter;
+
+  /// The HTTP client used to make HTTP requests.
+  http.Client? _httpClient;
+
+  /// The URL to which the resource owner will be redirected after they
+  /// authorize this client with the authorization server.
+  Uri? _redirectEndpoint;
+
+  /// The scopes that the client is requesting access to.
+  List<String>? _scopes;
+
+  /// An opaque string that users of this library may specify that will be
+  /// included in the response query parameters.
+  String? _stateString;
+
+  /// The current state of the grant object.
+  _State _state = _State.initial;
+
+  /// Allowed characters for generating the _codeVerifier
+  static const String _charset =
+      'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
+
+  /// The PKCE code verifier. Will be generated if one is not provided in the
+  /// constructor.
+  final String _codeVerifier;
+
+  /// Creates a new grant.
+  ///
+  /// If [basicAuth] is `true` (the default), the client credentials are sent to
+  /// the server using using HTTP Basic authentication as defined in [RFC 2617].
+  /// Otherwise, they're included in the request body. Note that the latter form
+  /// is not recommended by the OAuth 2.0 spec, and should only be used if the
+  /// server doesn't support Basic authentication.
+  ///
+  /// [RFC 2617]: https://tools.ietf.org/html/rfc2617
+  ///
+  /// [httpClient] is used for all HTTP requests made by this grant, as well as
+  /// those of the [Client] is constructs.
+  ///
+  /// [onCredentialsRefreshed] will be called by the constructed [Client]
+  /// whenever the credentials are refreshed.
+  ///
+  /// [codeVerifier] String to be used as PKCE code verifier. If none is
+  /// provided a random codeVerifier will be generated.
+  /// The codeVerifier must meet requirements specified in [RFC 7636].
+  ///
+  /// [RFC 7636]: https://tools.ietf.org/html/rfc7636#section-4.1
+  ///
+  /// The scope strings will be separated by the provided [delimiter]. This
+  /// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's)
+  /// use non-standard delimiters.
+  ///
+  /// By default, this follows the OAuth2 spec and requires the server's
+  /// responses to be in JSON format. However, some servers return non-standard
+  /// response formats, which can be parsed using the [getParameters] function.
+  ///
+  /// This function is passed the `Content-Type` header of the response as well
+  /// as its body as a UTF-8-decoded string. It should return a map in the same
+  /// format as the [standard JSON response][].
+  ///
+  /// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1
+  AuthorizationCodeGrant(
+      this.identifier, this.authorizationEndpoint, this.tokenEndpoint,
+      {this.secret,
+      String? delimiter,
+      bool basicAuth = true,
+      http.Client? httpClient,
+      CredentialsRefreshedCallback? onCredentialsRefreshed,
+      Map<String, dynamic> Function(MediaType? contentType, String body)?
+          getParameters,
+      String? codeVerifier})
+      : _basicAuth = basicAuth,
+        _httpClient = httpClient ?? http.Client(),
+        _delimiter = delimiter ?? ' ',
+        _getParameters = getParameters ?? parseJsonParameters,
+        _onCredentialsRefreshed = onCredentialsRefreshed,
+        _codeVerifier = codeVerifier ?? _createCodeVerifier();
+
+  /// Returns the URL to which the resource owner should be redirected to
+  /// authorize this client.
+  ///
+  /// The resource owner will then be redirected to [redirect], which should
+  /// point to a server controlled by the client. This redirect will have
+  /// additional query parameters that should be passed to
+  /// [handleAuthorizationResponse].
+  ///
+  /// The specific permissions being requested from the authorization server may
+  /// be specified via [scopes]. The scope strings are specific to the
+  /// authorization server and may be found in its documentation. Note that you
+  /// may not be granted access to every scope you request; you may check the
+  /// [Credentials.scopes] field of [Client.credentials] to see which scopes you
+  /// were granted.
+  ///
+  /// An opaque [state] string may also be passed that will be present in the
+  /// query parameters provided to the redirect URL.
+  ///
+  /// It is a [StateError] to call this more than once.
+  Uri getAuthorizationUrl(Uri redirect,
+      {Iterable<String>? scopes, String? state}) {
+    if (_state != _State.initial) {
+      throw StateError('The authorization URL has already been generated.');
+    }
+    _state = _State.awaitingResponse;
+
+    var scopeList = scopes?.toList() ?? <String>[];
+    var codeChallenge = base64Url
+        .encode(sha256.convert(ascii.encode(_codeVerifier)).bytes)
+        .replaceAll('=', '');
+
+    _redirectEndpoint = redirect;
+    _scopes = scopeList;
+    _stateString = state;
+    var parameters = {
+      'response_type': 'code',
+      'client_id': identifier,
+      'redirect_uri': redirect.toString(),
+      'code_challenge': codeChallenge,
+      'code_challenge_method': 'S256'
+    };
+
+    if (state != null) parameters['state'] = state;
+    if (scopeList.isNotEmpty) parameters['scope'] = scopeList.join(_delimiter);
+
+    return addQueryParameters(authorizationEndpoint, parameters);
+  }
+
+  /// Processes the query parameters added to a redirect from the authorization
+  /// server.
+  ///
+  /// Note that this "response" is not an HTTP response, but rather the data
+  /// passed to a server controlled by the client as query parameters on the
+  /// redirect URL.
+  ///
+  /// It is a [StateError] to call this more than once, to call it before
+  /// [getAuthorizationUrl] is called, or to call it after
+  /// [handleAuthorizationCode] is called.
+  ///
+  /// Throws [FormatException] if [parameters] is invalid according to the
+  /// OAuth2 spec or if the authorization server otherwise provides invalid
+  /// responses. If `state` was passed to [getAuthorizationUrl], this will throw
+  /// a [FormatException] if the `state` parameter doesn't match the original
+  /// value.
+  ///
+  /// Throws [AuthorizationException] if the authorization fails.
+  Future<Client> handleAuthorizationResponse(
+      Map<String, String> parameters) async {
+    if (_state == _State.initial) {
+      throw StateError('The authorization URL has not yet been generated.');
+    } else if (_state == _State.finished) {
+      throw StateError('The authorization code has already been received.');
+    }
+    _state = _State.finished;
+
+    if (_stateString != null) {
+      if (!parameters.containsKey('state')) {
+        throw FormatException('Invalid OAuth response for '
+            '"$authorizationEndpoint": parameter "state" expected to be '
+            '"$_stateString", was missing.');
+      } else if (parameters['state'] != _stateString) {
+        throw FormatException('Invalid OAuth response for '
+            '"$authorizationEndpoint": parameter "state" expected to be '
+            '"$_stateString", was "${parameters['state']}".');
+      }
+    }
+
+    if (parameters.containsKey('error')) {
+      var description = parameters['error_description'];
+      var uriString = parameters['error_uri'];
+      var uri = uriString == null ? null : Uri.parse(uriString);
+      throw AuthorizationException(parameters['error']!, description, uri);
+    } else if (!parameters.containsKey('code')) {
+      throw FormatException('Invalid OAuth response for '
+          '"$authorizationEndpoint": did not contain required parameter '
+          '"code".');
+    }
+
+    return _handleAuthorizationCode(parameters['code']);
+  }
+
+  /// Processes an authorization code directly.
+  ///
+  /// Usually [handleAuthorizationResponse] is preferable to this method, since
+  /// it validates all of the query parameters. However, some authorization
+  /// servers allow the user to copy and paste an authorization code into a
+  /// command-line application, in which case this method must be used.
+  ///
+  /// It is a [StateError] to call this more than once, to call it before
+  /// [getAuthorizationUrl] is called, or to call it after
+  /// [handleAuthorizationCode] is called.
+  ///
+  /// Throws [FormatException] if the authorization server provides invalid
+  /// responses while retrieving credentials.
+  ///
+  /// Throws [AuthorizationException] if the authorization fails.
+  Future<Client> handleAuthorizationCode(String authorizationCode) async {
+    if (_state == _State.initial) {
+      throw StateError('The authorization URL has not yet been generated.');
+    } else if (_state == _State.finished) {
+      throw StateError('The authorization code has already been received.');
+    }
+    _state = _State.finished;
+
+    return _handleAuthorizationCode(authorizationCode);
+  }
+
+  /// This works just like [handleAuthorizationCode], except it doesn't validate
+  /// the state beforehand.
+  Future<Client> _handleAuthorizationCode(String? authorizationCode) async {
+    var startTime = DateTime.now();
+
+    var headers = <String, String>{};
+
+    var body = {
+      'grant_type': 'authorization_code',
+      'code': authorizationCode,
+      'redirect_uri': _redirectEndpoint.toString(),
+      'code_verifier': _codeVerifier
+    };
+
+    var secret = this.secret;
+    if (_basicAuth && secret != null) {
+      headers['Authorization'] = basicAuthHeader(identifier, secret);
+    } else {
+      // The ID is required for this request any time basic auth isn't being
+      // used, even if there's no actual client authentication to be done.
+      body['client_id'] = identifier;
+      if (secret != null) body['client_secret'] = secret;
+    }
+
+    var response =
+        await _httpClient!.post(tokenEndpoint, headers: headers, body: body);
+
+    var credentials = handleAccessTokenResponse(
+        response, tokenEndpoint, startTime, _scopes, _delimiter,
+        getParameters: _getParameters);
+    return Client(credentials,
+        identifier: identifier,
+        secret: secret,
+        basicAuth: _basicAuth,
+        httpClient: _httpClient,
+        onCredentialsRefreshed: _onCredentialsRefreshed);
+  }
+
+  // Randomly generate a 128 character string to be used as the PKCE code
+  // verifier.
+  static String _createCodeVerifier() => List.generate(
+        128,
+        (i) => _charset[Random.secure().nextInt(_charset.length)],
+      ).join();
+
+  /// Closes the grant and frees its resources.
+  ///
+  /// This will close the underlying HTTP client, which is shared by the
+  /// [Client] created by this grant, so it's not safe to close the grant and
+  /// continue using the client.
+  void close() {
+    _httpClient?.close();
+    _httpClient = null;
+  }
+}
+
+/// States that [AuthorizationCodeGrant] can be in.
+class _State {
+  /// [AuthorizationCodeGrant.getAuthorizationUrl] has not yet been called for
+  /// this grant.
+  static const initial = _State('initial');
+
+  // [AuthorizationCodeGrant.getAuthorizationUrl] has been called but neither
+  // [AuthorizationCodeGrant.handleAuthorizationResponse] nor
+  // [AuthorizationCodeGrant.handleAuthorizationCode] has been called.
+  static const awaitingResponse = _State('awaiting response');
+
+  // [AuthorizationCodeGrant.getAuthorizationUrl] and either
+  // [AuthorizationCodeGrant.handleAuthorizationResponse] or
+  // [AuthorizationCodeGrant.handleAuthorizationCode] have been called.
+  static const finished = _State('finished');
+
+  final String _name;
+
+  const _State(this._name);
+
+  @override
+  String toString() => _name;
+}
diff --git a/lib/src/third_party/oauth2/lib/src/authorization_exception.dart b/lib/src/third_party/oauth2/lib/src/authorization_exception.dart
new file mode 100644
index 0000000..14a5a3c
--- /dev/null
+++ b/lib/src/third_party/oauth2/lib/src/authorization_exception.dart
@@ -0,0 +1,39 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+/// An exception raised when OAuth2 authorization fails.
+class AuthorizationException implements Exception {
+  /// The name of the error.
+  ///
+  /// Possible names are enumerated in [the spec][].
+  ///
+  /// [the spec]: http://tools.ietf.org/html/draft-ietf-oauth-v2-31#section-5.2
+  final String error;
+
+  /// The description of the error, provided by the server.
+  ///
+  /// May be `null` if the server provided no description.
+  final String? description;
+
+  /// A URL for a page that describes the error in more detail, provided by the
+  /// server.
+  ///
+  /// May be `null` if the server provided no URL.
+  final Uri? uri;
+
+  /// Creates an AuthorizationException.
+  AuthorizationException(this.error, this.description, this.uri);
+
+  /// Provides a string description of the AuthorizationException.
+  @override
+  String toString() {
+    var header = 'OAuth authorization error ($error)';
+    if (description != null) {
+      header = '$header: $description';
+    } else if (uri != null) {
+      header = '$header: $uri';
+    }
+    return '$header.';
+  }
+}
diff --git a/lib/src/third_party/oauth2/lib/src/client.dart b/lib/src/third_party/oauth2/lib/src/client.dart
new file mode 100644
index 0000000..1dd2282
--- /dev/null
+++ b/lib/src/third_party/oauth2/lib/src/client.dart
@@ -0,0 +1,187 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:collection/collection.dart';
+import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
+
+import 'authorization_exception.dart';
+import 'credentials.dart';
+import 'expiration_exception.dart';
+
+/// An OAuth2 client.
+///
+/// This acts as a drop-in replacement for an [http.Client], while sending
+/// OAuth2 authorization credentials along with each request.
+///
+/// The client also automatically refreshes its credentials if possible. When it
+/// makes a request, if its credentials are expired, it will first refresh them.
+/// This means that any request may throw an [AuthorizationException] if the
+/// refresh is not authorized for some reason, a [FormatException] if the
+/// authorization server provides ill-formatted responses, or an
+/// [ExpirationException] if the credentials are expired and can't be refreshed.
+///
+/// The client will also throw an [AuthorizationException] if the resource
+/// server returns a 401 response with a WWW-Authenticate header indicating that
+/// the current credentials are invalid.
+///
+/// If you already have a set of [Credentials], you can construct a [Client]
+/// directly. However, in order to first obtain the credentials, you must
+/// authorize. At the time of writing, the only authorization method this
+/// library supports is [AuthorizationCodeGrant].
+class Client extends http.BaseClient {
+  /// The client identifier for this client.
+  ///
+  /// The authorization server will issue each client a separate client
+  /// identifier and secret, which allows the server to tell which client is
+  /// accessing it. Some servers may also have an anonymous identifier/secret
+  /// pair that any client may use.
+  ///
+  /// This is usually global to the program using this library.
+  final String? identifier;
+
+  /// The client secret for this client.
+  ///
+  /// The authorization server will issue each client a separate client
+  /// identifier and secret, which allows the server to tell which client is
+  /// accessing it. Some servers may also have an anonymous identifier/secret
+  /// pair that any client may use.
+  ///
+  /// This is usually global to the program using this library.
+  ///
+  /// Note that clients whose source code or binary executable is readily
+  /// available may not be able to make sure the client secret is kept a secret.
+  /// This is fine; OAuth2 servers generally won't rely on knowing with
+  /// certainty that a client is who it claims to be.
+  final String? secret;
+
+  /// The credentials this client uses to prove to the resource server that it's
+  /// authorized.
+  ///
+  /// This may change from request to request as the credentials expire and the
+  /// client refreshes them automatically.
+  Credentials get credentials => _credentials;
+  Credentials _credentials;
+
+  /// Callback to be invoked whenever the credentials refreshed.
+  final CredentialsRefreshedCallback? _onCredentialsRefreshed;
+
+  /// Whether to use HTTP Basic authentication for authorizing the client.
+  final bool _basicAuth;
+
+  /// The underlying HTTP client.
+  http.Client? _httpClient;
+
+  /// Creates a new client from a pre-existing set of credentials.
+  ///
+  /// When authorizing a client for the first time, you should use
+  /// [AuthorizationCodeGrant] or [resourceOwnerPasswordGrant] instead of
+  /// constructing a [Client] directly.
+  ///
+  /// [httpClient] is the underlying client that this forwards requests to after
+  /// adding authorization credentials to them.
+  ///
+  /// Throws an [ArgumentError] if [secret] is passed without [identifier].
+  Client(this._credentials,
+      {this.identifier,
+      this.secret,
+      CredentialsRefreshedCallback? onCredentialsRefreshed,
+      bool basicAuth = true,
+      http.Client? httpClient})
+      : _basicAuth = basicAuth,
+        _onCredentialsRefreshed = onCredentialsRefreshed,
+        _httpClient = httpClient ?? http.Client() {
+    if (identifier == null && secret != null) {
+      throw ArgumentError('secret may not be passed without identifier.');
+    }
+  }
+
+  /// Sends an HTTP request with OAuth2 authorization credentials attached.
+  ///
+  /// This will also automatically refresh this client's [Credentials] before
+  /// sending the request if necessary.
+  @override
+  Future<http.StreamedResponse> send(http.BaseRequest request) async {
+    if (credentials.isExpired) {
+      if (!credentials.canRefresh) throw ExpirationException(credentials);
+      await refreshCredentials();
+    }
+
+    request.headers['authorization'] = 'Bearer ${credentials.accessToken}';
+    var response = await _httpClient!.send(request);
+
+    if (response.statusCode != 401) return response;
+    if (!response.headers.containsKey('www-authenticate')) return response;
+
+    List<AuthenticationChallenge> challenges;
+    try {
+      challenges = AuthenticationChallenge.parseHeader(
+          response.headers['www-authenticate']!);
+    } on FormatException {
+      return response;
+    }
+
+    var challenge = challenges
+        .firstWhereOrNull((challenge) => challenge.scheme == 'bearer');
+    if (challenge == null) return response;
+
+    var params = challenge.parameters;
+    if (!params.containsKey('error')) return response;
+
+    throw AuthorizationException(params['error']!, params['error_description'],
+        params['error_uri'] == null ? null : Uri.parse(params['error_uri']!));
+  }
+
+  /// A [Future] used to track whether [refreshCredentials] is running.
+  Future<Credentials>? _refreshingFuture;
+
+  /// Explicitly refreshes this client's credentials. Returns this client.
+  ///
+  /// This will throw a [StateError] if the [Credentials] can't be refreshed, an
+  /// [AuthorizationException] if refreshing the credentials fails, or a
+  /// [FormatException] if the authorization server returns invalid responses.
+  ///
+  /// You may request different scopes than the default by passing in
+  /// [newScopes]. These must be a subset of the scopes in the
+  /// [Credentials.scopes] field of [Client.credentials].
+  Future<Client> refreshCredentials([List<String>? newScopes]) async {
+    if (!credentials.canRefresh) {
+      var prefix = 'OAuth credentials';
+      if (credentials.isExpired) prefix = '$prefix have expired and';
+      throw StateError("$prefix can't be refreshed.");
+    }
+
+    // To make sure that only one refresh happens when credentials are expired
+    // we track it using the [_refreshingFuture]. And also make sure that the
+    // _onCredentialsRefreshed callback is only called once.
+    if (_refreshingFuture == null) {
+      try {
+        _refreshingFuture = credentials.refresh(
+          identifier: identifier,
+          secret: secret,
+          newScopes: newScopes,
+          basicAuth: _basicAuth,
+          httpClient: _httpClient,
+        );
+        _credentials = await _refreshingFuture!;
+        _onCredentialsRefreshed?.call(_credentials);
+      } finally {
+        _refreshingFuture = null;
+      }
+    } else {
+      await _refreshingFuture;
+    }
+
+    return this;
+  }
+
+  /// Closes this client and its underlying HTTP client.
+  @override
+  void close() {
+    _httpClient?.close();
+    _httpClient = null;
+  }
+}
diff --git a/lib/src/third_party/oauth2/lib/src/client_credentials_grant.dart b/lib/src/third_party/oauth2/lib/src/client_credentials_grant.dart
new file mode 100644
index 0000000..045d1a0
--- /dev/null
+++ b/lib/src/third_party/oauth2/lib/src/client_credentials_grant.dart
@@ -0,0 +1,79 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
+
+import 'client.dart';
+import 'handle_access_token_response.dart';
+import 'utils.dart';
+
+/// Obtains credentials using a [client credentials grant](https://tools.ietf.org/html/rfc6749#section-1.3.4).
+///
+/// This mode of authorization uses the client's [identifier] and [secret]
+/// to obtain an authorization token from the authorization server, instead
+/// of sending a user through a dedicated flow.
+///
+/// The client [identifier] and [secret] are required, and are
+/// used to identify and authenticate your specific OAuth2 client. These are
+/// usually global to the program using this library.
+///
+/// The specific permissions being requested from the authorization server may
+/// be specified via [scopes]. The scope strings are specific to the
+/// authorization server and may be found in its documentation. Note that you
+/// may not be granted access to every scope you request; you may check the
+/// [Credentials.scopes] field of [Client.credentials] to see which scopes you
+/// were granted.
+///
+/// The scope strings will be separated by the provided [delimiter]. This
+/// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's)
+/// use non-standard delimiters.
+///
+/// By default, this follows the OAuth2 spec and requires the server's responses
+/// to be in JSON format. However, some servers return non-standard response
+/// formats, which can be parsed using the [getParameters] function.
+///
+/// This function is passed the `Content-Type` header of the response as well as
+/// its body as a UTF-8-decoded string. It should return a map in the same
+/// format as the [standard JSON response](https://tools.ietf.org/html/rfc6749#section-5.1)
+Future<Client> clientCredentialsGrant(
+    Uri authorizationEndpoint, String? identifier, String? secret,
+    {Iterable<String>? scopes,
+    bool basicAuth = true,
+    http.Client? httpClient,
+    String? delimiter,
+    Map<String, dynamic> Function(MediaType? contentType, String body)?
+        getParameters}) async {
+  delimiter ??= ' ';
+  var startTime = DateTime.now();
+
+  var body = {'grant_type': 'client_credentials'};
+
+  var headers = <String, String>{};
+
+  if (identifier != null) {
+    if (basicAuth) {
+      headers['Authorization'] = basicAuthHeader(identifier, secret!);
+    } else {
+      body['client_id'] = identifier;
+      if (secret != null) body['client_secret'] = secret;
+    }
+  }
+
+  if (scopes != null && scopes.isNotEmpty) {
+    body['scope'] = scopes.join(delimiter);
+  }
+
+  httpClient ??= http.Client();
+  var response = await httpClient.post(authorizationEndpoint,
+      headers: headers, body: body);
+
+  var credentials = handleAccessTokenResponse(response, authorizationEndpoint,
+      startTime, scopes?.toList() ?? [], delimiter,
+      getParameters: getParameters);
+  return Client(credentials,
+      identifier: identifier, secret: secret, httpClient: httpClient);
+}
diff --git a/lib/src/third_party/oauth2/lib/src/credentials.dart b/lib/src/third_party/oauth2/lib/src/credentials.dart
new file mode 100644
index 0000000..459e63e
--- /dev/null
+++ b/lib/src/third_party/oauth2/lib/src/credentials.dart
@@ -0,0 +1,267 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:collection';
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
+
+import 'handle_access_token_response.dart';
+import 'parameters.dart';
+import 'utils.dart';
+
+/// Type of the callback when credentials are refreshed.
+typedef CredentialsRefreshedCallback = void Function(Credentials);
+
+/// Credentials that prove that a client is allowed to access a resource on the
+/// resource owner's behalf.
+///
+/// These credentials are long-lasting and can be safely persisted across
+/// multiple runs of the program.
+///
+/// Many authorization servers will attach an expiration date to a set of
+/// credentials, along with a token that can be used to refresh the credentials
+/// once they've expired. The [Client] will automatically refresh its
+/// credentials when necessary. It's also possible to explicitly refresh them
+/// via [Client.refreshCredentials] or [Credentials.refresh].
+///
+/// Note that a given set of credentials can only be refreshed once, so be sure
+/// to save the refreshed credentials for future use.
+class Credentials {
+  /// A [String] used to separate scopes; defaults to `" "`.
+  String _delimiter;
+
+  /// The token that is sent to the resource server to prove the authorization
+  /// of a client.
+  final String accessToken;
+
+  /// The token that is sent to the authorization server to refresh the
+  /// credentials.
+  ///
+  /// This may be `null`, indicating that the credentials can't be refreshed.
+  final String? refreshToken;
+
+  /// The token that is received from the authorization server to enable
+  /// End-Users to be Authenticated, contains Claims, represented as a
+  /// JSON Web Token (JWT).
+  ///
+  /// This may be `null`, indicating that the 'openid' scope was not
+  /// requested (or not supported).
+  ///
+  /// [spec]: https://openid.net/specs/openid-connect-core-1_0.html#IDToken
+  final String? idToken;
+
+  /// The URL of the authorization server endpoint that's used to refresh the
+  /// credentials.
+  ///
+  /// This may be `null`, indicating that the credentials can't be refreshed.
+  final Uri? tokenEndpoint;
+
+  /// The specific permissions being requested from the authorization server.
+  ///
+  /// The scope strings are specific to the authorization server and may be
+  /// found in its documentation.
+  final List<String>? scopes;
+
+  /// The date at which these credentials will expire.
+  ///
+  /// This is likely to be a few seconds earlier than the server's idea of the
+  /// expiration date.
+  final DateTime? expiration;
+
+  /// The function used to parse parameters from a host's response.
+  final GetParameters _getParameters;
+
+  /// Whether or not these credentials have expired.
+  ///
+  /// Note that it's possible the credentials will expire shortly after this is
+  /// called. However, since the client's expiration date is kept a few seconds
+  /// earlier than the server's, there should be enough leeway to rely on this.
+  bool get isExpired {
+    var expiration = this.expiration;
+    return expiration != null && DateTime.now().isAfter(expiration);
+  }
+
+  /// Whether it's possible to refresh these credentials.
+  bool get canRefresh => refreshToken != null && tokenEndpoint != null;
+
+  /// Creates a new set of credentials.
+  ///
+  /// This class is usually not constructed directly; rather, it's accessed via
+  /// [Client.credentials] after a [Client] is created by
+  /// [AuthorizationCodeGrant]. Alternately, it may be loaded from a serialized
+  /// form via [Credentials.fromJson].
+  ///
+  /// The scope strings will be separated by the provided [delimiter]. This
+  /// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's)
+  /// use non-standard delimiters.
+  ///
+  /// By default, this follows the OAuth2 spec and requires the server's
+  /// responses to be in JSON format. However, some servers return non-standard
+  /// response formats, which can be parsed using the [getParameters] function.
+  ///
+  /// This function is passed the `Content-Type` header of the response as well
+  /// as its body as a UTF-8-decoded string. It should return a map in the same
+  /// format as the [standard JSON response][].
+  ///
+  /// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1
+  Credentials(this.accessToken,
+      {this.refreshToken,
+      this.idToken,
+      this.tokenEndpoint,
+      Iterable<String>? scopes,
+      this.expiration,
+      String? delimiter,
+      Map<String, dynamic> Function(MediaType? mediaType, String body)?
+          getParameters})
+      : scopes = UnmodifiableListView(
+            // Explicitly type-annotate the list literal to work around
+            // sdk#24202.
+            scopes == null ? <String>[] : scopes.toList()),
+        _delimiter = delimiter ?? ' ',
+        _getParameters = getParameters ?? parseJsonParameters;
+
+  /// Loads a set of credentials from a JSON-serialized form.
+  ///
+  /// Throws a [FormatException] if the JSON is incorrectly formatted.
+  factory Credentials.fromJson(String json) {
+    void validate(bool condition, message) {
+      if (condition) return;
+      throw FormatException('Failed to load credentials: $message.\n\n$json');
+    }
+
+    dynamic parsed;
+    try {
+      parsed = jsonDecode(json);
+    } on FormatException {
+      validate(false, 'invalid JSON');
+    }
+
+    validate(parsed is Map, 'was not a JSON map');
+
+    parsed = parsed as Map;
+    validate(parsed.containsKey('accessToken'),
+        'did not contain required field "accessToken"');
+    validate(
+      parsed['accessToken'] is String,
+      'required field "accessToken" was not a string, was '
+      '${parsed["accessToken"]}',
+    );
+
+    for (var stringField in ['refreshToken', 'idToken', 'tokenEndpoint']) {
+      var value = parsed[stringField];
+      validate(value == null || value is String,
+          'field "$stringField" was not a string, was "$value"');
+    }
+
+    var scopes = parsed['scopes'];
+    validate(scopes == null || scopes is List,
+        'field "scopes" was not a list, was "$scopes"');
+
+    var tokenEndpoint = parsed['tokenEndpoint'];
+    Uri? tokenEndpointUri;
+    if (tokenEndpoint != null) {
+      tokenEndpointUri = Uri.parse(tokenEndpoint as String);
+    }
+
+    var expiration = parsed['expiration'];
+    DateTime? expirationDateTime;
+    if (expiration != null) {
+      validate(expiration is int,
+          'field "expiration" was not an int, was "$expiration"');
+      expiration = expiration as int;
+      expirationDateTime = DateTime.fromMillisecondsSinceEpoch(expiration);
+    }
+
+    return Credentials(
+      parsed['accessToken'] as String,
+      refreshToken: parsed['refreshToken'] as String?,
+      idToken: parsed['idToken'] as String?,
+      tokenEndpoint: tokenEndpointUri,
+      scopes: (scopes as List).map((scope) => scope as String),
+      expiration: expirationDateTime,
+    );
+  }
+
+  /// Serializes a set of credentials to JSON.
+  ///
+  /// Nothing is guaranteed about the output except that it's valid JSON and
+  /// compatible with [Credentials.toJson].
+  String toJson() => jsonEncode({
+        'accessToken': accessToken,
+        'refreshToken': refreshToken,
+        'idToken': idToken,
+        'tokenEndpoint': tokenEndpoint?.toString(),
+        'scopes': scopes,
+        'expiration': expiration?.millisecondsSinceEpoch
+      });
+
+  /// Returns a new set of refreshed credentials.
+  ///
+  /// See [Client.identifier] and [Client.secret] for explanations of those
+  /// parameters.
+  ///
+  /// You may request different scopes than the default by passing in
+  /// [newScopes]. These must be a subset of [scopes].
+  ///
+  /// This throws an [ArgumentError] if [secret] is passed without [identifier],
+  /// a [StateError] if these credentials can't be refreshed, an
+  /// [AuthorizationException] if refreshing the credentials fails, or a
+  /// [FormatException] if the authorization server returns invalid responses.
+  Future<Credentials> refresh(
+      {String? identifier,
+      String? secret,
+      Iterable<String>? newScopes,
+      bool basicAuth = true,
+      http.Client? httpClient}) async {
+    var scopes = this.scopes;
+    if (newScopes != null) scopes = newScopes.toList();
+    scopes ??= [];
+    httpClient ??= http.Client();
+
+    if (identifier == null && secret != null) {
+      throw ArgumentError('secret may not be passed without identifier.');
+    }
+
+    var startTime = DateTime.now();
+    var tokenEndpoint = this.tokenEndpoint;
+    if (refreshToken == null) {
+      throw StateError("Can't refresh credentials without a refresh "
+          'token.');
+    } else if (tokenEndpoint == null) {
+      throw StateError("Can't refresh credentials without a token "
+          'endpoint.');
+    }
+
+    var headers = <String, String>{};
+
+    var body = {'grant_type': 'refresh_token', 'refresh_token': refreshToken};
+    if (scopes.isNotEmpty) body['scope'] = scopes.join(_delimiter);
+
+    if (basicAuth && secret != null) {
+      headers['Authorization'] = basicAuthHeader(identifier!, secret);
+    } else {
+      if (identifier != null) body['client_id'] = identifier;
+      if (secret != null) body['client_secret'] = secret;
+    }
+
+    var response =
+        await httpClient.post(tokenEndpoint, headers: headers, body: body);
+    var credentials = handleAccessTokenResponse(
+        response, tokenEndpoint, startTime, scopes, _delimiter,
+        getParameters: _getParameters);
+
+    // The authorization server may issue a new refresh token. If it doesn't,
+    // we should re-use the one we already have.
+    if (credentials.refreshToken != null) return credentials;
+    return Credentials(credentials.accessToken,
+        refreshToken: refreshToken,
+        idToken: credentials.idToken,
+        tokenEndpoint: credentials.tokenEndpoint,
+        scopes: credentials.scopes,
+        expiration: credentials.expiration);
+  }
+}
diff --git a/lib/src/third_party/oauth2/lib/src/expiration_exception.dart b/lib/src/third_party/oauth2/lib/src/expiration_exception.dart
new file mode 100644
index 0000000..d72fcf6
--- /dev/null
+++ b/lib/src/third_party/oauth2/lib/src/expiration_exception.dart
@@ -0,0 +1,19 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'credentials.dart';
+
+/// An exception raised when attempting to use expired OAuth2 credentials.
+class ExpirationException implements Exception {
+  /// The expired credentials.
+  final Credentials credentials;
+
+  /// Creates an ExpirationException.
+  ExpirationException(this.credentials);
+
+  /// Provides a string description of the ExpirationException.
+  @override
+  String toString() =>
+      "OAuth2 credentials have expired and can't be refreshed.";
+}
diff --git a/lib/src/third_party/oauth2/lib/src/handle_access_token_response.dart b/lib/src/third_party/oauth2/lib/src/handle_access_token_response.dart
new file mode 100644
index 0000000..931ae9d
--- /dev/null
+++ b/lib/src/third_party/oauth2/lib/src/handle_access_token_response.dart
@@ -0,0 +1,156 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
+
+import 'authorization_exception.dart';
+import 'credentials.dart';
+import 'parameters.dart';
+
+/// The amount of time to add as a "grace period" for credential expiration.
+///
+/// This allows credential expiration checks to remain valid for a reasonable
+/// amount of time.
+const _expirationGrace = Duration(seconds: 10);
+
+/// Handles a response from the authorization server that contains an access
+/// token.
+///
+/// This response format is common across several different components of the
+/// OAuth2 flow.
+///
+/// By default, this follows the OAuth2 spec and requires the server's responses
+/// to be in JSON format. However, some servers return non-standard response
+/// formats, which can be parsed using the [getParameters] function.
+///
+/// This function is passed the `Content-Type` header of the response as well as
+/// its body as a UTF-8-decoded string. It should return a map in the same
+/// format as the [standard JSON response][].
+///
+/// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1
+Credentials handleAccessTokenResponse(http.Response response, Uri tokenEndpoint,
+    DateTime startTime, List<String>? scopes, String delimiter,
+    {Map<String, dynamic> Function(MediaType? contentType, String body)?
+        getParameters}) {
+  getParameters ??= parseJsonParameters;
+
+  try {
+    if (response.statusCode != 200) {
+      _handleErrorResponse(response, tokenEndpoint, getParameters);
+    }
+
+    var contentTypeString = response.headers['content-type'];
+    if (contentTypeString == null) {
+      throw const FormatException('Missing Content-Type string.');
+    }
+
+    var parameters =
+        getParameters(MediaType.parse(contentTypeString), response.body);
+
+    for (var requiredParameter in ['access_token', 'token_type']) {
+      if (!parameters.containsKey(requiredParameter)) {
+        throw FormatException(
+            'did not contain required parameter "$requiredParameter"');
+      } else if (parameters[requiredParameter] is! String) {
+        throw FormatException(
+            'required parameter "$requiredParameter" was not a string, was '
+            '"${parameters[requiredParameter]}"');
+      }
+    }
+
+    // TODO(nweiz): support the "mac" token type
+    // (http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01)
+    if ((parameters['token_type'] as String).toLowerCase() != 'bearer') {
+      throw FormatException(
+          '"$tokenEndpoint": unknown token type "${parameters['token_type']}"');
+    }
+
+    var expiresIn = parameters['expires_in'];
+    if (expiresIn != null) {
+      if (expiresIn is String) {
+        try {
+          expiresIn = double.parse(expiresIn).toInt();
+        } on FormatException {
+          throw FormatException(
+              'parameter "expires_in" could not be parsed as in, was: "$expiresIn"');
+        }
+      } else if (expiresIn is! int) {
+        throw FormatException(
+            'parameter "expires_in" was not an int, was: "$expiresIn"');
+      }
+    }
+
+    for (var name in ['refresh_token', 'id_token', 'scope']) {
+      var value = parameters[name];
+      if (value != null && value is! String) {
+        throw FormatException(
+            'parameter "$name" was not a string, was "$value"');
+      }
+    }
+
+    var scope = parameters['scope'] as String?;
+    if (scope != null) scopes = scope.split(delimiter);
+
+    var expiration = expiresIn == null
+        ? null
+        : startTime.add(Duration(seconds: expiresIn as int) - _expirationGrace);
+
+    return Credentials(
+      parameters['access_token'] as String,
+      refreshToken: parameters['refresh_token'] as String?,
+      idToken: parameters['id_token'] as String?,
+      tokenEndpoint: tokenEndpoint,
+      scopes: scopes,
+      expiration: expiration,
+    );
+  } on FormatException catch (e) {
+    throw FormatException('Invalid OAuth response for "$tokenEndpoint": '
+        '${e.message}.\n\n${response.body}');
+  }
+}
+
+/// Throws the appropriate exception for an error response from the
+/// authorization server.
+void _handleErrorResponse(
+    http.Response response, Uri tokenEndpoint, GetParameters getParameters) {
+  // OAuth2 mandates a 400 or 401 response code for access token error
+  // responses. If it's not a 400 reponse, the server is either broken or
+  // off-spec.
+  if (response.statusCode != 400 && response.statusCode != 401) {
+    var reason = '';
+    var reasonPhrase = response.reasonPhrase;
+    if (reasonPhrase != null && reasonPhrase.isNotEmpty) {
+      reason = ' $reasonPhrase';
+    }
+    throw FormatException('OAuth request for "$tokenEndpoint" failed '
+        'with status ${response.statusCode}$reason.\n\n${response.body}');
+  }
+
+  var contentTypeString = response.headers['content-type'];
+  var contentType =
+      contentTypeString == null ? null : MediaType.parse(contentTypeString);
+
+  var parameters = getParameters(contentType, response.body);
+
+  if (!parameters.containsKey('error')) {
+    throw const FormatException('did not contain required parameter "error"');
+  } else if (parameters['error'] is! String) {
+    throw FormatException('required parameter "error" was not a string, was '
+        '"${parameters["error"]}"');
+  }
+
+  for (var name in ['error_description', 'error_uri']) {
+    var value = parameters[name];
+
+    if (value != null && value is! String) {
+      throw FormatException('parameter "$name" was not a string, was "$value"');
+    }
+  }
+
+  var uriString = parameters['error_uri'] as String?;
+  var uri = uriString == null ? null : Uri.parse(uriString);
+  var description = parameters['error_description'] as String?;
+  throw AuthorizationException(parameters['error'] as String, description, uri);
+}
diff --git a/lib/src/third_party/oauth2/lib/src/parameters.dart b/lib/src/third_party/oauth2/lib/src/parameters.dart
new file mode 100644
index 0000000..ecc6559
--- /dev/null
+++ b/lib/src/third_party/oauth2/lib/src/parameters.dart
@@ -0,0 +1,33 @@
+// Copyright (c) 2018, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+
+import 'package:http_parser/http_parser.dart';
+
+/// The type of a callback that parses parameters from an HTTP response.
+typedef GetParameters = Map<String, dynamic> Function(
+    MediaType? contentType, String body);
+
+/// Parses parameters from a response with a JSON body, as per the
+/// [OAuth2 spec][].
+///
+/// [OAuth2 spec]: https://tools.ietf.org/html/rfc6749#section-5.1
+Map<String, dynamic> parseJsonParameters(MediaType? contentType, String body) {
+  // The spec requires a content-type of application/json, but some endpoints
+  // (e.g. Dropbox) serve it as text/javascript instead.
+  if (contentType == null ||
+      (contentType.mimeType != 'application/json' &&
+          contentType.mimeType != 'text/javascript')) {
+    throw FormatException(
+        'Content-Type was "$contentType", expected "application/json"');
+  }
+
+  var untypedParameters = jsonDecode(body);
+  if (untypedParameters is Map<String, dynamic>) {
+    return untypedParameters;
+  }
+
+  throw FormatException('Parameters must be a map, was "$untypedParameters"');
+}
diff --git a/lib/src/third_party/oauth2/lib/src/resource_owner_password_grant.dart b/lib/src/third_party/oauth2/lib/src/resource_owner_password_grant.dart
new file mode 100644
index 0000000..96fb503
--- /dev/null
+++ b/lib/src/third_party/oauth2/lib/src/resource_owner_password_grant.dart
@@ -0,0 +1,94 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
+
+import 'client.dart';
+import 'credentials.dart';
+import 'handle_access_token_response.dart';
+import 'utils.dart';
+
+/// Obtains credentials using a [resource owner password grant](https://tools.ietf.org/html/rfc6749#section-1.3.3).
+///
+/// This mode of authorization uses the user's username and password to obtain
+/// an authentication token, which can then be stored. This is safer than
+/// storing the username and password directly, but it should be avoided if any
+/// other authorization method is available, since it requires the user to
+/// provide their username and password to a third party (you).
+///
+/// The client [identifier] and [secret] may be issued by the server, and are
+/// used to identify and authenticate your specific OAuth2 client. These are
+/// usually global to the program using this library.
+///
+/// The specific permissions being requested from the authorization server may
+/// be specified via [scopes]. The scope strings are specific to the
+/// authorization server and may be found in its documentation. Note that you
+/// may not be granted access to every scope you request; you may check the
+/// [Credentials.scopes] field of [Client.credentials] to see which scopes you
+/// were granted.
+///
+/// The scope strings will be separated by the provided [delimiter]. This
+/// defaults to `" "`, the OAuth2 standard, but some APIs (such as Facebook's)
+/// use non-standard delimiters.
+///
+/// By default, this follows the OAuth2 spec and requires the server's responses
+/// to be in JSON format. However, some servers return non-standard response
+/// formats, which can be parsed using the [getParameters] function.
+///
+/// This function is passed the `Content-Type` header of the response as well as
+/// its body as a UTF-8-decoded string. It should return a map in the same
+/// format as the [standard JSON response][].
+///
+/// [standard JSON response]: https://tools.ietf.org/html/rfc6749#section-5.1
+Future<Client> resourceOwnerPasswordGrant(
+    Uri authorizationEndpoint, String username, String password,
+    {String? identifier,
+    String? secret,
+    Iterable<String>? scopes,
+    bool basicAuth = true,
+    CredentialsRefreshedCallback? onCredentialsRefreshed,
+    http.Client? httpClient,
+    String? delimiter,
+    Map<String, dynamic> Function(MediaType? contentType, String body)?
+        getParameters}) async {
+  delimiter ??= ' ';
+  var startTime = DateTime.now();
+
+  var body = {
+    'grant_type': 'password',
+    'username': username,
+    'password': password
+  };
+
+  var headers = <String, String>{};
+
+  if (identifier != null) {
+    if (basicAuth) {
+      headers['Authorization'] = basicAuthHeader(identifier, secret!);
+    } else {
+      body['client_id'] = identifier;
+      if (secret != null) body['client_secret'] = secret;
+    }
+  }
+
+  if (scopes != null && scopes.isNotEmpty) {
+    body['scope'] = scopes.join(delimiter);
+  }
+
+  httpClient ??= http.Client();
+  var response = await httpClient.post(authorizationEndpoint,
+      headers: headers, body: body);
+
+  var credentials = handleAccessTokenResponse(
+      response, authorizationEndpoint, startTime, scopes?.toList(), delimiter,
+      getParameters: getParameters);
+  return Client(credentials,
+      identifier: identifier,
+      secret: secret,
+      httpClient: httpClient,
+      onCredentialsRefreshed: onCredentialsRefreshed);
+}
diff --git a/lib/src/third_party/oauth2/lib/src/utils.dart b/lib/src/third_party/oauth2/lib/src/utils.dart
new file mode 100644
index 0000000..2a22b9f
--- /dev/null
+++ b/lib/src/third_party/oauth2/lib/src/utils.dart
@@ -0,0 +1,15 @@
+// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+
+/// Adds additional query parameters to [url], overwriting the original
+/// parameters if a name conflict occurs.
+Uri addQueryParameters(Uri url, Map<String, String> parameters) => url.replace(
+    queryParameters: Map.from(url.queryParameters)..addAll(parameters));
+
+String basicAuthHeader(String identifier, String secret) {
+  var userPass = '${Uri.encodeFull(identifier)}:${Uri.encodeFull(secret)}';
+  return 'Basic ${base64Encode(ascii.encode(userPass))}';
+}
diff --git a/lib/src/third_party/oauth2/vendored-pubspec.yaml b/lib/src/third_party/oauth2/vendored-pubspec.yaml
new file mode 100644
index 0000000..bb43bb7
--- /dev/null
+++ b/lib/src/third_party/oauth2/vendored-pubspec.yaml
@@ -0,0 +1,20 @@
+name: oauth2
+version: 2.0.1
+description: >-
+  A client library for authenticating with a remote service via OAuth2 on
+  behalf of a user, and making authorized HTTP requests with the user's
+  OAuth2 credentials.
+repository: https://github.com/dart-lang/oauth2
+  
+environment:
+  sdk: '>=2.17.0 <3.0.0'
+
+dependencies:
+  collection: ^1.15.0
+  crypto: ^3.0.0
+  http: ^0.13.0
+  http_parser: ^4.0.0
+
+dev_dependencies:
+  lints: ^2.0.0
+  test: ^1.16.0
diff --git a/lib/src/third_party/tar/CHANGELOG.md b/lib/src/third_party/tar/CHANGELOG.md
new file mode 100644
index 0000000..a804552
--- /dev/null
+++ b/lib/src/third_party/tar/CHANGELOG.md
@@ -0,0 +1,86 @@
+## 0.5.6
+
+- Allow cancelling a `TarEntry.contents` subscription before reading more files.
+
+## 0.5.5+1
+
+- No user-visible changes.
+
+## 0.5.5
+
+- Fix a crash when pausing a subscription to `TarEntry.contents` right before
+  it ends.
+
+## 0.5.4
+
+- Fix generating corrupt tar files when adding lots of entries at very high
+  speeds [(#20)](https://github.com/simolus3/tar/issues/20).
+- Allow tar files with invalid utf8 content in PAX header values if those
+  values aren't used for anything important.
+
+## 0.5.3
+
+- Improve error messages when reading a tar entry after, or during, a call to
+  `moveNext()`.
+
+## 0.5.2
+
+- This package now supports being compiled to JavaScript.
+
+## 0.5.1
+
+- Improve performance when reading large archives
+
+## 0.5.0
+
+- Support sync encoding with `tarConverter`.
+
+## 0.4.0
+
+- Support generating tar files with GNU-style long link names
+ - Add `format` parameter to `tarWritingSink` and `tarWriterWith`
+
+## 0.3.3
+
+- Drop `chunked_stream` dependency in favor of `package:async`.
+
+## 0.3.2
+
+- Allow arbitrarily many zero bytes at the end of an archive when
+  `disallowTrailingData` is enabled.
+
+## 0.3.1
+
+- Add `disallowTrailingData` parameter to `TarReader`. When the option is set,
+  `readNext` will ensure that the input stream does not emit further data after
+  the tar archive has been read fully.
+
+## 0.3.0
+
+- Remove outdated references in the documentation
+
+## 0.3.0-nullsafety.0
+
+- Remove `TarReader.contents` and `TarReader.header`. Use `current.contents` and `current.header`, respectively.
+- Fix some minor implementation details
+
+## 0.2.0-nullsafety
+
+Most of the tar package has been rewritten, it's now based on the
+implementation written by [Garett Tok Ern Liang](https://github.com/walnutdust)
+in the GSoC 2020.
+
+- Added `tar` prefix to exported symbols.
+- Remove `MemoryEntry`. Use `TarEntry.data` to create a tar entry from bytes.
+- Make `WritingSink` private. Use `tarWritingSink` to create a general `StreamSink<tar.Entry>`.
+- `TarReader` is now a [`StreamIterator`](https://api.dart.dev/stable/2.10.4/dart-async/StreamIterator-class.html),
+  the transformer had some design flaws.
+
+## 0.1.0-nullsafety.1
+
+- Support writing user and group names
+- Better support for PAX-headers and large files
+
+## 0.1.0-nullsafety.0
+
+- Initial version
diff --git a/lib/src/third_party/tar/README.md b/lib/src/third_party/tar/README.md
index 5e12e5a..8d5a334 100644
--- a/lib/src/third_party/tar/README.md
+++ b/lib/src/third_party/tar/README.md
@@ -1,7 +1,214 @@
-# package:tar
+# tar
 
-Vendored elements from `package:tar` for use in creation and extraction of
-tar-archives.
+![Build status](https://github.com/simolus3/tar/workflows/build/badge.svg)
 
- * Repository: `https://github.com/simolus3/tar/`
- * Revision: `23ee71d667f003fba8c80ee126d5e1330d17c141`
+This package provides stream-based readers and writers for tar files.
+
+When working with large tar files, this library consumes considerably less memory
+than [package:archive](https://pub.dev/packages/archive), although it is slightly slower due to the async overhead.
+
+## Reading
+
+To read entries from a tar file, use a `TarReader` with a `Stream` emitting bytes (as `List<int>`):
+
+```dart
+import 'dart:convert';
+import 'dart:io';
+import 'package:tar/tar.dart';
+
+Future<void> main() async {
+  final reader = TarReader(File('file.tar').openRead());
+
+  while (await reader.moveNext()) {
+    final entry = reader.current;
+    // Use reader.header to see the header of the current tar entry
+    print(entry.header.name);
+    // And reader.contents to read the content of the current entry as a stream
+    print(await entry.contents.transform(utf8.decoder).first);
+  }
+  // Note that the reader will automatically close if moveNext() returns false or
+  // throws. If you want to close a tar stream before that happens, use
+  // reader.cancel();
+}
+```
+
+To read `.tar.gz` files, transform the stream with `gzip.decoder` before
+passing it to the `TarReader`.
+
+To easily go through all entries in a tar file, use `TarReader.forEach`:
+
+```dart
+Future<void> main() async {
+  final inputStream = File('file.tar').openRead();
+
+  await TarReader.forEach(inputStream, (entry) {
+    print(header.name);
+    print(await entry.contents.transform(utf8.decoder).first);
+  });
+}
+```
+
+__Warning__: Since the reader is backed by a single stream, concurrent calls to
+`read` are not allowed! Similarly, if you're reading from an entry's `contents`,
+make sure to fully drain the stream before calling `read()` again.
+_Not_ subscribing to `contents` before calling `moveNext()` is acceptable too.
+In this case, the reader will implicitly drain the stream.
+The reader detects concurrency misuses and will throw an error when they occur,
+there's no risk of reading faulty data.
+
+## Writing
+
+When writing archives, `package:tar` expects a `Stream` of tar entries to include in
+the archive.
+This stream can then be converted into a stream of byte-array chunks forming the
+encoded tar archive.
+
+To write a tar stream into a `StreamSink<List<int>>`, such as an `IOSink` returned by
+`File.openWrite`, use `tarWritingSink`:
+
+```dart
+import 'dart:convert';
+import 'dart:io';
+import 'package:tar/tar.dart';
+
+Future<void> main() async {
+  final output = File('test.tar').openWrite();
+  final tarEntries = Stream<TarEntry>.value(
+    TarEntry.data(
+      TarHeader(
+        name: 'hello.txt',
+        mode: int.parse('644', radix: 8),
+      ),
+      utf8.encode('Hello world'),
+    ),
+  );
+
+  await tarEntries.pipe(tarWritingSink(output));
+}
+```
+
+For more complex stream transformations, `tarWriter` can be used as a stream
+transformer converting a stream of tar entries into archive bytes.
+
+Together with the `gzip.encoder` transformer from `dart:io`, this can be used
+to write a `.tar.gz` file:
+
+```dart
+import 'dart:io';
+import 'package:tar/tar.dart';
+
+Future<void> write(Stream<TarEntry> entries) {
+  return entries
+      .transform(tarWriter) // convert entries into a .tar stream
+      .transform(gzip.encoder) // convert the .tar stream into a .tar.gz stream
+      .pipe(File('output.tar.gz').openWrite());
+}
+```
+
+A more complex example for writing files can be found in [`example/archive_self.dart`](example/archive_self.dart).
+
+### Encoding options
+
+By default, tar files are  written in the pax format defined by the
+POSIX.1-2001 specification (`--format=posix` in GNU tar).
+When all entries have file names shorter than 100 chars and a size smaller
+than 8 GB, this is equivalent to the `ustar` format. This library won't write
+PAX headers when there is no reason to do so.
+If you prefer writing GNU-style long filenames instead, you can use the
+`format` option:
+
+```dart
+Future<void> write(Stream<TarEntry> entries) {
+  return entries
+      .pipe(
+        tarWritingSink(
+          File('output.tar').openWrite(),
+          format: OutputFormat.gnuLongName,
+      ));
+}
+```
+
+To change the output format on the `tarWriter` transformer, use
+`tarWriterWith`.
+
+### Synchronous writing
+
+As the content of tar entries is defined as an asynchronous stream, the tar encoder is asynchronous too.
+The more specific `SynchronousTarEntry` class stores tar content as a list of bytes, meaning that it can be
+written synchronously.
+
+To synchronously write tar files, use `tarConverter` (or `tarConverterWith` for options):
+
+```dart
+List<int> createTarArchive(Iterable<SynchronousTarEntry> entries) {
+  late List<int> result;
+  final sink = ByteConversionSink.withCallback((data) => result = data);
+
+  final output = tarConverter.startChunkedConversion(sink);
+  entries.forEach(output.add);
+  output.close();
+
+  return result;
+}
+```
+
+## Features
+
+- Supports v7, ustar, pax, gnu and star archives
+- Supports extended pax headers for long file or link names
+- Supports long file and link names generated by GNU-tar
+- Hardened against denial-of-service attacks with invalid tar files
+- Supports being compiled to JavaScript, tested on Node.js
+
+## Security considerations
+
+Internally, this package contains checks to guard against some invalid tar files.
+In particular,
+
+- The reader doesn't allocate memory based on values in a tar file (so there's
+  a guard against DoS attacks with tar files containing huge headers).
+- When encountering malformed tar files, the reader will throw a `TarException`.
+  Any other exception thrown indicates a bug in `package:tar` or how it's used.
+  The reader should never crash.
+- Reading a tar file can be cancelled mid-stream without leaking resources.
+
+However, the tar reader __does not__ throw exceptions for wellformed archives
+with suspicious contents, such as
+
+- File names beginning with `../`, `/` or names pointing out of the archive by
+  other means.
+- Link references to files outside of the archive.
+- Paths not using forward slashes.
+- Gzip + tar bombs.
+- Invalid permission bits in entries.
+- ...
+
+When reading or extracting untrusted tar files, it is your responsibility to
+detect and handle these cases.
+For instance, this naive extraction function is susceptible to invalid tar
+files containing paths outside of the target directory:
+
+```dart
+Future<void> extractTarGz(File tarGz, Directory target) async {
+  final input = tarGz.openRead().transform(gzip.decoder);
+
+  await TarReader.forEach(input, (entry) async {
+    final destination =
+        // DON'T DO THIS! If `entry.name` contained `../`, this may escape the
+        // target directory.
+        path.joinAll([target.path, ...path.posix.split(entry.name)]);
+
+    final f = File(destination);
+    await f.create(recursive: true);
+    await entry.contents.pipe(f.openWrite());
+  });
+}
+```
+
+For an idea on how to guard against this, see the [extraction logic](https://github.com/dart-lang/pub/blob/3082796f8ba9b3f509265ac3a223312fb5033988/lib/src/io.dart#L904-L991)
+used by the pub client.
+
+-----
+
+Big thanks to [Garett Tok Ern Liang](https://github.com/walnutdust) for writing the initial
+Dart tar reader that this library is based on.
diff --git a/lib/src/third_party/tar/analysis_options.yaml b/lib/src/third_party/tar/analysis_options.yaml
new file mode 100644
index 0000000..8b18047
--- /dev/null
+++ b/lib/src/third_party/tar/analysis_options.yaml
@@ -0,0 +1,20 @@
+include: package:extra_pedantic/analysis_options.2.0.0.yaml
+
+analyzer:
+  strong-mode:
+    implicit-casts: false
+    implicit-dynamic: false
+  language:
+    strict-inference: true
+    strict-raw-types: true
+
+linter:
+  rules:
+    close_sinks: false # This rule has just too many false-positives...
+    comment_references: true
+    literal_only_boolean_expressions: false # Nothing wrong with a little while(true)
+    parameter_assignments: false
+    unnecessary_await_in_return: false
+    no_default_cases: false
+    prefer_asserts_with_message: false # We only use asserts for library-internal invariants
+    prefer_final_parameters: false # Too much noise
diff --git a/lib/src/third_party/tar/src/charcodes.dart b/lib/src/third_party/tar/lib/src/charcodes.dart
similarity index 100%
rename from lib/src/third_party/tar/src/charcodes.dart
rename to lib/src/third_party/tar/lib/src/charcodes.dart
diff --git a/lib/src/third_party/tar/src/constants.dart b/lib/src/third_party/tar/lib/src/constants.dart
similarity index 100%
rename from lib/src/third_party/tar/src/constants.dart
rename to lib/src/third_party/tar/lib/src/constants.dart
diff --git a/lib/src/third_party/tar/src/entry.dart b/lib/src/third_party/tar/lib/src/entry.dart
similarity index 100%
rename from lib/src/third_party/tar/src/entry.dart
rename to lib/src/third_party/tar/lib/src/entry.dart
diff --git a/lib/src/third_party/tar/src/exception.dart b/lib/src/third_party/tar/lib/src/exception.dart
similarity index 100%
rename from lib/src/third_party/tar/src/exception.dart
rename to lib/src/third_party/tar/lib/src/exception.dart
diff --git a/lib/src/third_party/tar/src/format.dart b/lib/src/third_party/tar/lib/src/format.dart
similarity index 100%
rename from lib/src/third_party/tar/src/format.dart
rename to lib/src/third_party/tar/lib/src/format.dart
diff --git a/lib/src/third_party/tar/src/header.dart b/lib/src/third_party/tar/lib/src/header.dart
similarity index 100%
rename from lib/src/third_party/tar/src/header.dart
rename to lib/src/third_party/tar/lib/src/header.dart
diff --git a/lib/src/third_party/tar/src/reader.dart b/lib/src/third_party/tar/lib/src/reader.dart
similarity index 100%
rename from lib/src/third_party/tar/src/reader.dart
rename to lib/src/third_party/tar/lib/src/reader.dart
diff --git a/lib/src/third_party/tar/src/sparse.dart b/lib/src/third_party/tar/lib/src/sparse.dart
similarity index 100%
rename from lib/src/third_party/tar/src/sparse.dart
rename to lib/src/third_party/tar/lib/src/sparse.dart
diff --git a/lib/src/third_party/tar/src/utils.dart b/lib/src/third_party/tar/lib/src/utils.dart
similarity index 100%
rename from lib/src/third_party/tar/src/utils.dart
rename to lib/src/third_party/tar/lib/src/utils.dart
diff --git a/lib/src/third_party/tar/src/writer.dart b/lib/src/third_party/tar/lib/src/writer.dart
similarity index 100%
rename from lib/src/third_party/tar/src/writer.dart
rename to lib/src/third_party/tar/lib/src/writer.dart
diff --git a/lib/src/third_party/tar/tar.dart b/lib/src/third_party/tar/lib/tar.dart
similarity index 100%
rename from lib/src/third_party/tar/tar.dart
rename to lib/src/third_party/tar/lib/tar.dart
diff --git a/lib/src/third_party/tar/vendored-pubspec.yaml b/lib/src/third_party/tar/vendored-pubspec.yaml
new file mode 100644
index 0000000..0556ca8
--- /dev/null
+++ b/lib/src/third_party/tar/vendored-pubspec.yaml
@@ -0,0 +1,24 @@
+name: tar
+description: Memory-efficient, streaming implementation of the tar file format
+version: 0.5.6
+repository: https://github.com/simolus3/tar/
+
+environment:
+  sdk: '>=2.12.0 <3.0.0'
+
+dependencies:
+  async: ^2.6.0
+  meta: ^1.3.0
+  typed_data: ^1.3.0
+
+dev_dependencies:
+  charcode: ^1.2.0
+  extra_pedantic: ^3.0.0
+  file: ^6.1.2
+  node_io: ^2.1.0
+  path: ^1.8.0
+  test: ^1.20.0
+
+dependency_overrides:
+  # Waiting for https://github.com/pulyaevskiy/node-interop/issues/110
+  file: '>=6.1.0 <6.1.3'
diff --git a/lib/src/third_party/vendor-state.yaml b/lib/src/third_party/vendor-state.yaml
new file mode 100644
index 0000000..09c5eeb
--- /dev/null
+++ b/lib/src/third_party/vendor-state.yaml
@@ -0,0 +1,29 @@
+# DO NOT EDIT: This file is generated by package:vendor version 0.9.0
+version: 0.9.0
+config:
+  import_rewrites:
+    oauth2: oauth2
+    tar: tar
+  vendored_dependencies:
+    oauth2:
+      package: oauth2
+      version: 2.0.1
+      import_rewrites: {}
+      include:
+        - pubspec.yaml
+        - README.md
+        - LICENSE
+        - CHANGELOG.md
+        - lib/**
+        - analysis_options.yaml
+    tar:
+      package: tar
+      version: 0.5.6
+      import_rewrites: {}
+      include:
+        - pubspec.yaml
+        - README.md
+        - LICENSE
+        - CHANGELOG.md
+        - lib/**
+        - analysis_options.yaml
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 0f1bf51..0719bbb 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -693,7 +693,7 @@
   Duration maxDelay = const Duration(seconds: 30),
   int maxAttempts = 8,
   FutureOr<bool> Function(Exception)? retryIf,
-  FutureOr<void> Function(Exception, int retryCount)? onRetry,
+  FutureOr<void> Function(Exception, int attemptNumber)? onRetry,
 }) async {
   var attempt = 0;
   // ignore: literal_only_boolean_expressions
@@ -705,8 +705,9 @@
       if (attempt >= maxAttempts || (retryIf != null && !(await retryIf(e)))) {
         rethrow;
       }
+
       if (onRetry != null) {
-        await onRetry(e, attempt);
+        await onRetry(e, attempt + 1);
       }
     }
 
diff --git a/lib/src/validator/analyze.dart b/lib/src/validator/analyze.dart
index b9af306..b1e70a2 100644
--- a/lib/src/validator/analyze.dart
+++ b/lib/src/validator/analyze.dart
@@ -14,13 +14,21 @@
 
 /// Runs `dart analyze` and gives a warning if it returns non-zero.
 class AnalyzeValidator extends Validator {
+  /// Only analyze dart code in the following sub-folders.
   @override
   Future<void> validate() async {
-    final result = await runProcess(Platform.resolvedExecutable, [
-      'analyze',
-      '--fatal-infos',
-      if (!p.equals(entrypoint.root.dir, p.current)) entrypoint.root.dir,
-    ]);
+    final dirsToAnalyze = ['lib', 'test', 'bin']
+        .map((dir) => p.join(entrypoint.root.dir, dir))
+        .where(dirExists);
+    final result = await runProcess(
+      Platform.resolvedExecutable,
+      [
+        'analyze',
+        '--fatal-infos',
+        ...dirsToAnalyze,
+        p.join(entrypoint.root.dir, 'pubspec.yaml')
+      ],
+    );
     if (result.exitCode != 0) {
       final limitedOutput = limitLength(result.stdout.join('\n'), 1000);
       warnings
diff --git a/pubspec.yaml b/pubspec.yaml
index 00c9829..cc655dc 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -18,14 +18,15 @@
   http_multi_server: ^3.0.1
   http_parser: ^4.0.1
   meta: ^1.3.0
-  oauth2: ^2.0.0
   path: ^1.8.0
   pool: ^1.5.0
   pub_semver: ^2.1.0
   shelf: ^1.1.1
   source_span: ^1.8.1
   stack_trace: ^1.10.0
+  typed_data: ^1.3.1
   usage: ^4.0.2
+  vendor: ^0.9.2
   yaml: ^3.1.0
   yaml_edit: ^2.0.0
 
@@ -35,3 +36,4 @@
   test: ^1.21.5
   test_descriptor: ^2.0.0
   test_process: ^2.0.0
+
diff --git a/test/add/hosted/non_default_pub_server_test.dart b/test/add/hosted/non_default_pub_server_test.dart
index 787bf32..f93f292 100644
--- a/test/add/hosted/non_default_pub_server_test.dart
+++ b/test/add/hosted/non_default_pub_server_test.dart
@@ -91,7 +91,8 @@
 
     await pubAdd(
       args: ['foo', '--hosted-url', 'https://invalid-url.foo'],
-      error: contains('Could not resolve URL "https://invalid-url.foo".'),
+      error: contains('Got socket error trying to find package foo at '
+          'https://invalid-url.foo.'),
       exitCode: exit_codes.DATA,
       environment: {
         // Limit the retries - the url will never go valid.
diff --git a/test/cache/preload_test.dart b/test/cache/preload_test.dart
new file mode 100644
index 0000000..3bf5f64
--- /dev/null
+++ b/test/cache/preload_test.dart
@@ -0,0 +1,180 @@
+// Copyright (c) 2013, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:io';
+
+import 'package:http/http.dart';
+import 'package:path/path.dart' as p;
+import 'package:pub/src/exit_codes.dart';
+import 'package:test/test.dart';
+
+import '../descriptor.dart' as d;
+import '../descriptor.dart';
+import '../test_pub.dart';
+
+void main() {
+  test('adds correct entries to cache and stores the content-hash', () async {
+    final server = await servePackages();
+    server.serve('foo', '1.0.0');
+    server.serve('foo', '2.0.0');
+
+    await appDir({'foo': '^2.0.0'}).create();
+    // Do a `pub get` here to create a lock file in order to validate we later can
+    // `pub get --offline` with packages installed by `preload`.
+    await pubGet();
+
+    await runPub(args: ['cache', 'clean', '-f']);
+
+    final archivePath1 = p.join(sandbox, 'foo-1.0.0-archive.tar.gz');
+    final archivePath2 = p.join(sandbox, 'foo-2.0.0-archive.tar.gz');
+
+    File(archivePath1).writeAsBytesSync(await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/1.0.0.tar.gz')));
+    File(archivePath2).writeAsBytesSync(await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/2.0.0.tar.gz')));
+    await runPub(
+      args: ['cache', 'preload', archivePath1, archivePath2],
+      environment: {'_PUB_TEST_DEFAULT_HOSTED_URL': server.url},
+      output: allOf(
+        [
+          contains('Installed $archivePath1 in cache as foo 1.0.0.'),
+          contains('Installed $archivePath2 in cache as foo 2.0.0.'),
+        ],
+      ),
+    );
+    await d.cacheDir({'foo': '1.0.0'}).validate();
+    await d.cacheDir({'foo': '2.0.0'}).validate();
+
+    await hostedHashesCache([
+      file('foo-1.0.0.sha256', await server.peekArchiveSha256('foo', '1.0.0')),
+    ]).validate();
+
+    await hostedHashesCache([
+      file('foo-2.0.0.sha256', await server.peekArchiveSha256('foo', '2.0.0')),
+    ]).validate();
+
+    await pubGet(args: ['--offline']);
+  });
+
+  test(
+      'installs package according to PUB_HOSTED_URL even on non-offical server',
+      () async {
+    final server = await servePackages();
+    server.serve('foo', '1.0.0');
+
+    final archivePath = p.join(sandbox, 'archive');
+
+    File(archivePath).writeAsBytesSync(await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/1.0.0.tar.gz')));
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      // By having pub.dev be the "official" server the test-server (localhost)
+      // is considered non-official. Test that the output mentions that we
+      // are installing to a non-official server.
+      environment: {'_PUB_TEST_DEFAULT_HOSTED_URL': 'pub.dev'},
+      output: allOf([
+        contains(
+            'Installed $archivePath in cache as foo 1.0.0 from ${server.url}.')
+      ]),
+    );
+    await d.cacheDir({'foo': '1.0.0'}).validate();
+  });
+
+  test('overwrites existing entry in cache', () async {
+    final server = await servePackages();
+    server.serve('foo', '1.0.0', contents: [file('old-file.txt')]);
+
+    final archivePath = p.join(sandbox, 'archive');
+
+    File(archivePath).writeAsBytesSync(
+      await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/1.0.0.tar.gz'),
+      ),
+    );
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      environment: {'_PUB_TEST_DEFAULT_HOSTED_URL': server.url},
+      output:
+          allOf([contains('Installed $archivePath in cache as foo 1.0.0.')]),
+    );
+
+    server.serve('foo', '1.0.0', contents: [file('new-file.txt')]);
+
+    File(archivePath).writeAsBytesSync(
+      await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/1.0.0.tar.gz'),
+      ),
+    );
+
+    File(archivePath).writeAsBytesSync(
+      await readBytes(
+        Uri.parse(server.url).resolve('packages/foo/versions/1.0.0.tar.gz'),
+      ),
+    );
+
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      environment: {'_PUB_TEST_DEFAULT_HOSTED_URL': server.url},
+      output:
+          allOf([contains('Installed $archivePath in cache as foo 1.0.0.')]),
+    );
+    await hostedCache([
+      dir('foo-1.0.0', [file('new-file.txt'), nothing('old-file.txt')])
+    ]).validate();
+  });
+
+  test('handles missing archive', () async {
+    final archivePath = p.join(sandbox, 'archive');
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      error: contains('Could not find file $archivePath.'),
+      exitCode: 1,
+    );
+  });
+
+  test('handles broken archives', () async {
+    final archivePath = p.join(sandbox, 'archive');
+    File(archivePath).writeAsBytesSync('garbage'.codeUnits);
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      error:
+          contains('Failed to extract `$archivePath`: Filter error, bad data.'),
+      exitCode: DATA,
+    );
+  });
+
+  test('handles missing pubspec.yaml in archive', () async {
+    final archivePath = p.join(sandbox, 'archive');
+
+    // Create a tar.gz with a single file (and no pubspec.yaml).
+    File(archivePath).writeAsBytesSync(
+      await tarFromDescriptors([d.file('foo.txt')]).expand((x) => x).toList(),
+    );
+
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      error: contains(
+        'Found no `pubspec.yaml` in $archivePath. Is it a valid pub package archive?',
+      ),
+      exitCode: 1,
+    );
+  });
+
+  test('handles broken pubspec.yaml in archive', () async {
+    final archivePath = p.join(sandbox, 'archive');
+
+    File(archivePath).writeAsBytesSync(
+        await tarFromDescriptors([d.file('pubspec.yaml', '{}')])
+            .expand((x) => x)
+            .toList());
+
+    await runPub(
+      args: ['cache', 'preload', archivePath],
+      error: contains(
+        'Failed to load `pubspec.yaml` from `$archivePath`: Error on line 1, column 1',
+      ),
+      exitCode: 1,
+    );
+  });
+}
diff --git a/test/content_hash_test.dart b/test/content_hash_test.dart
index 6a0725b..2485918 100644
--- a/test/content_hash_test.dart
+++ b/test/content_hash_test.dart
@@ -54,7 +54,8 @@
         'e7a7a0f6d9873e4c40cf68cc3cc9ca5b6c8cef6a2220241bdada4b9cb0083279');
     await appDir({'foo': 'any'}).create();
     await pubGet(
-      silent: contains('Retry #2'),
+      exitCode: exit_codes.TEMP_FAIL,
+      silent: contains('Attempt #2'),
       error:
           contains('Downloaded archive for foo-1.0.0 had wrong content-hash.'),
       environment: {
diff --git a/test/descriptor.dart b/test/descriptor.dart
index 4ced393..ab3a7ec 100644
--- a/test/descriptor.dart
+++ b/test/descriptor.dart
@@ -5,10 +5,10 @@
 /// Pub-specific test descriptors.
 import 'dart:convert';
 
-import 'package:oauth2/oauth2.dart' as oauth2;
 import 'package:path/path.dart' as p;
 import 'package:pub/src/language_version.dart';
 import 'package:pub/src/package_config.dart';
+import 'package:pub/src/third_party/oauth2/lib/oauth2.dart' as oauth2;
 import 'package:test_descriptor/test_descriptor.dart';
 
 import 'descriptor/git.dart';
@@ -96,15 +96,23 @@
 /// [name], [version], and [deps]. If "sdk" is given, then it adds an SDK
 /// constraint on that version, otherwise it adds an SDK constraint allowing
 /// the current SDK version.
-Descriptor libPubspec(String name, String version,
-    {Map? deps, Map? devDeps, String? sdk}) {
+///
+/// [extras] is additional fields of the pubspec.
+Descriptor libPubspec(
+  String name,
+  String version, {
+  Map? deps,
+  Map? devDeps,
+  String? sdk,
+  Map<String, Object> extras = const {},
+}) {
   var map = packageMap(name, version, deps, devDeps);
   if (sdk != null) {
     map['environment'] = {'sdk': sdk};
   } else {
     map['environment'] = {'sdk': '>=0.1.2 <1.0.0'};
   }
-  return pubspec(map);
+  return pubspec({...map, ...extras});
 }
 
 /// Describes a file named `pubspec_overrides.yaml` by default, with the given
diff --git a/test/get/hosted/get_test.dart b/test/get/hosted/get_test.dart
index 81d4845..837fb32 100644
--- a/test/get/hosted/get_test.dart
+++ b/test/get/hosted/get_test.dart
@@ -139,11 +139,12 @@
     }).create();
 
     await pubGet(
+      exitCode: exit_codes.TEMP_FAIL,
       error: RegExp(
           r'''Package archive for foo 1.2.3 downloaded from "(.+)" has '''
           r'''"x-goog-hash: crc32c=(\d+)", which doesn't match the checksum '''
           r'''of the archive downloaded\.'''),
-      silent: contains('Retry #2 because of checksum error'),
+      silent: contains('Attempt #2'),
       environment: {
         'PUB_MAX_HTTP_RETRIES': '2',
       },
@@ -175,11 +176,11 @@
       }).create();
 
       await pubGet(
-        exitCode: exit_codes.DATA,
+        exitCode: exit_codes.TEMP_FAIL,
         error: contains(
             'Package archive "foo-1.2.3.tar.gz" has a malformed CRC32C '
             'checksum in its response headers'),
-        silent: contains('Retry #2 because of checksum error'),
+        silent: contains('Attempt #2'),
         environment: {
           'PUB_MAX_HTTP_RETRIES': '2',
         },
@@ -195,11 +196,11 @@
       }).create();
 
       await pubGet(
-        exitCode: exit_codes.DATA,
+        exitCode: exit_codes.TEMP_FAIL,
         error: contains(
             'Package archive "bar-1.2.3.tar.gz" has a malformed CRC32C '
             'checksum in its response headers'),
-        silent: contains('Retry #2 because of checksum error'),
+        silent: contains('Attempt #2'),
         environment: {
           'PUB_MAX_HTTP_RETRIES': '2',
         },
@@ -215,11 +216,11 @@
       }).create();
 
       await pubGet(
-        exitCode: exit_codes.DATA,
+        exitCode: exit_codes.TEMP_FAIL,
         error: contains(
             'Package archive "baz-1.2.3.tar.gz" has a malformed CRC32C '
             'checksum in its response headers'),
-        silent: contains('Retry #2 because of checksum error'),
+        silent: contains('Attempt #2'),
         environment: {
           'PUB_MAX_HTTP_RETRIES': '2',
         },
diff --git a/test/hosted/fail_gracefully_on_url_resolve_test.dart b/test/hosted/fail_gracefully_on_url_resolve_test.dart
index 7a91e7a..0dff951 100644
--- a/test/hosted/fail_gracefully_on_url_resolve_test.dart
+++ b/test/hosted/fail_gracefully_on_url_resolve_test.dart
@@ -20,7 +20,8 @@
       ]).create();
 
       await pubCommand(command,
-          error: 'Could not resolve URL "https://invalid-url.foo".',
+          error: 'Got socket error trying to find package foo at '
+              'https://invalid-url.foo.',
           exitCode: exit_codes.UNAVAILABLE,
           environment: {
             'PUB_MAX_HTTP_RETRIES': '2',
diff --git a/test/io_test.dart b/test/io_test.dart
index 0657dd6..80bd1be 100644
--- a/test/io_test.dart
+++ b/test/io_test.dart
@@ -9,7 +9,7 @@
 import 'package:path/path.dart' as path;
 import 'package:pub/src/exceptions.dart';
 import 'package:pub/src/io.dart';
-import 'package:pub/src/third_party/tar/tar.dart';
+import 'package:pub/src/third_party/tar/lib/tar.dart';
 import 'package:test/test.dart';
 
 import 'descriptor.dart' as d;
diff --git a/test/package_server.dart b/test/package_server.dart
index 06a8d1b..d69d0ef 100644
--- a/test/package_server.dart
+++ b/test/package_server.dart
@@ -11,7 +11,6 @@
 import 'package:path/path.dart' as p;
 import 'package:pub/src/crc32c.dart';
 import 'package:pub/src/source/hosted.dart';
-import 'package:pub/src/third_party/tar/tar.dart';
 import 'package:pub/src/utils.dart' show hexEncode;
 import 'package:pub_semver/pub_semver.dart';
 import 'package:shelf/shelf.dart' as shelf;
@@ -239,60 +238,10 @@
     package.versions[version] = _ServedPackageVersion(
       pubspecFields,
       headers: headers,
-      contents: () {
-        final entries = <TarEntry>[];
-
-        void addDescriptor(d.Descriptor descriptor, String path) {
-          if (descriptor is d.DirectoryDescriptor) {
-            for (final e in descriptor.contents) {
-              addDescriptor(e, p.posix.join(path, descriptor.name));
-            }
-          } else {
-            entries.add(
-              TarEntry(
-                TarHeader(
-                  // Ensure paths in tar files use forward slashes
-                  name: p.posix.join(path, descriptor.name),
-                  // We want to keep executable bits, but otherwise use the default
-                  // file mode
-                  mode: 420,
-                  // size: 100,
-                  modified: DateTime.fromMicrosecondsSinceEpoch(0),
-                  userName: 'pub',
-                  groupName: 'pub',
-                ),
-                (descriptor as d.FileDescriptor).readAsBytes(),
-              ),
-            );
-          }
-        }
-
-        for (final e in contents ?? <d.Descriptor>[]) {
-          addDescriptor(e, '');
-        }
-        return _replaceOs(Stream.fromIterable(entries)
-            .transform(tarWriterWith(format: OutputFormat.gnuLongName))
-            .transform(gzip.encoder));
-      },
+      contents: () => tarFromDescriptors(contents ?? []),
     );
   }
 
-  /// Replaces the entry at index 9 in [stream] with a 0. This replaces the os
-  /// entry of a gzip stream, giving us the same stream and thius stable testing
-  /// on all platforms.
-  ///
-  /// See https://www.rfc-editor.org/rfc/rfc1952 section 2.3 for information
-  /// about the OS header.
-  Stream<List<int>> _replaceOs(Stream<List<int>> stream) async* {
-    final bytesBuilder = BytesBuilder();
-    await for (final t in stream) {
-      bytesBuilder.add(t);
-    }
-    final result = bytesBuilder.toBytes();
-    result[9] = 0;
-    yield result;
-  }
-
   // Mark a package discontinued.
   void discontinue(String name,
       {bool isDiscontinued = true, String? replacementText}) {
diff --git a/test/test_pub.dart b/test/test_pub.dart
index 58da455..a932b6b 100644
--- a/test/test_pub.dart
+++ b/test/test_pub.dart
@@ -9,9 +9,10 @@
 /// library provides an API to build tests like that.
 import 'dart:convert';
 import 'dart:core';
-import 'dart:io';
+import 'dart:io' hide BytesBuilder;
 import 'dart:isolate';
 import 'dart:math';
+import 'dart:typed_data';
 
 import 'package:async/async.dart';
 import 'package:http/testing.dart';
@@ -26,6 +27,7 @@
 import 'package:pub/src/package_name.dart';
 import 'package:pub/src/source/hosted.dart';
 import 'package:pub/src/system_cache.dart';
+import 'package:pub/src/third_party/tar/lib/tar.dart';
 import 'package:pub/src/utils.dart';
 import 'package:pub/src/validator.dart';
 import 'package:pub_semver/pub_semver.dart';
@@ -974,3 +976,54 @@
     'PATH': '$binFolder$separator${Platform.environment['PATH']}',
   };
 }
+
+Stream<List<int>> tarFromDescriptors(Iterable<d.Descriptor> contents) {
+  final entries = <TarEntry>[];
+  void addDescriptor(d.Descriptor descriptor, String path) {
+    if (descriptor is d.DirectoryDescriptor) {
+      for (final e in descriptor.contents) {
+        addDescriptor(e, p.posix.join(path, descriptor.name));
+      }
+    } else {
+      entries.add(
+        TarEntry(
+          TarHeader(
+            // Ensure paths in tar files use forward slashes
+            name: p.posix.join(path, descriptor.name),
+            // We want to keep executable bits, but otherwise use the default
+            // file mode
+            mode: 420,
+            // size: 100,
+            modified: DateTime.fromMicrosecondsSinceEpoch(0),
+            userName: 'pub',
+            groupName: 'pub',
+          ),
+          (descriptor as d.FileDescriptor).readAsBytes(),
+        ),
+      );
+    }
+  }
+
+  for (final e in contents) {
+    addDescriptor(e, '');
+  }
+  return _replaceOs(Stream.fromIterable(entries)
+      .transform(tarWriterWith(format: OutputFormat.gnuLongName))
+      .transform(gzip.encoder));
+}
+
+/// Replaces the entry at index 9 in [stream] with a 0. This replaces the os
+/// entry of a gzip stream, giving us the same stream and thius stable testing
+/// on all platforms.
+///
+/// See https://www.rfc-editor.org/rfc/rfc1952 section 2.3 for information
+/// about the OS header.
+Stream<List<int>> _replaceOs(Stream<List<int>> stream) async* {
+  final bytesBuilder = BytesBuilder();
+  await for (final t in stream) {
+    bytesBuilder.add(t);
+  }
+  final result = bytesBuilder.toBytes();
+  result[9] = 0;
+  yield result;
+}
diff --git a/test/validator/analyze_test.dart b/test/validator/analyze_test.dart
index 0717b3c..a5882db 100644
--- a/test/validator/analyze_test.dart
+++ b/test/validator/analyze_test.dart
@@ -40,14 +40,82 @@
     await expectValidation(contains('Package has 0 warnings.'), 0);
   });
 
-  test('should warn if package contains errors, and works with --directory',
+  test('should handle having no code in the analyzed directories', () async {
+    await d.dir(appPath, [
+      d.libPubspec('test_pkg', '1.0.0', sdk: '>=1.8.0 <=2.0.0'),
+      d.file('LICENSE', 'Eh, do what you want.'),
+      d.file('README.md', "This package isn't real."),
+      d.file('CHANGELOG.md', '# 1.0.0\nFirst version\n'),
+    ]).create();
+
+    await pubGet(environment: {'_PUB_TEST_SDK_VERSION': '1.12.0'});
+
+    await expectValidation(contains('Package has 0 warnings.'), 0);
+  });
+
+  test(
+      'follows analysis_options.yaml and should warn if package contains errors in pubspec.yaml',
+      () async {
+    await d.dir(appPath, [
+      d.libPubspec('test_pkg', '1.0.0',
+          sdk: '>=1.8.0 <=2.0.0',
+          // Using http where https is recommended.
+          extras: {'repository': 'http://repo.org/'}),
+      d.file('LICENSE', 'Eh, do what you want.'),
+      d.file('README.md', "This package isn't real."),
+      d.file('CHANGELOG.md', '# 1.0.0\nFirst version\n'),
+      d.file('analysis_options.yaml', '''
+linter:
+  rules:
+    - secure_pubspec_urls
+''')
+    ]).create();
+
+    await pubGet(environment: {'_PUB_TEST_SDK_VERSION': '1.12.0'});
+
+    await expectValidation(
+      allOf([
+        contains(
+            "The url should only use secure protocols. Try using 'https'."),
+        contains('Package has 1 warning.'),
+      ]),
+      DATA,
+    );
+  });
+
+  test(
+      'should consider a package valid even if it contains errors in the example/ sub-folder',
       () async {
     await d.dir(appPath, [
       d.libPubspec('test_pkg', '1.0.0', sdk: '>=1.8.0 <=2.0.0'),
       d.file('LICENSE', 'Eh, do what you want.'),
       d.file('README.md', "This package isn't real."),
       d.file('CHANGELOG.md', '# 1.0.0\nFirst version\n'),
-      d.dir('lib', [
+      d.dir('lib', [d.file('test_pkg.dart', 'int i = 1;')]),
+      d.dir('example', [
+        d.file('test_pkg.dart', '''
+void main() {
+  final a = 10; // Unused.
+}
+''')
+      ])
+    ]).create();
+
+    await pubGet(environment: {'_PUB_TEST_SDK_VERSION': '1.12.0'});
+
+    await expectValidation(contains('Package has 0 warnings.'), 0);
+  });
+
+  test(
+      'should warn if package contains errors in bin/, and works with --directory',
+      () async {
+    await d.dir(appPath, [
+      d.libPubspec('test_pkg', '1.0.0', sdk: '>=1.8.0 <=2.0.0'),
+      d.file('LICENSE', 'Eh, do what you want.'),
+      d.file('README.md', "This package isn't real."),
+      d.file('CHANGELOG.md', '# 1.0.0\nFirst version\n'),
+      d.dir('lib', [d.file('test_pkg.dart', 'int i = 1;')]),
+      d.dir('bin', [
         d.file('test_pkg.dart', '''
 void main() {
 // Missing }
@@ -60,7 +128,7 @@
     await expectValidation(
       allOf([
         contains('`dart analyze` found the following issue(s):'),
-        contains('Analyzing myapp...'),
+        contains('Analyzing lib, bin, pubspec.yaml...'),
         contains('error -'),
         contains("Expected to find '}'."),
         contains('Package has 1 warning.')
@@ -71,13 +139,14 @@
     );
   });
 
-  test('should warn if package contains infos', () async {
+  test('should warn if package contains infos in test folder', () async {
     await d.dir(appPath, [
       d.libPubspec('test_pkg', '1.0.0', sdk: '>=1.8.0 <=2.0.0'),
       d.file('LICENSE', 'Eh, do what you want.'),
       d.file('README.md', "This package isn't real."),
       d.file('CHANGELOG.md', '# 1.0.0\nFirst version\n'),
-      d.dir('lib', [
+      d.dir('lib', [d.file('test_pkg.dart', 'int i = 1;')]),
+      d.dir('test', [
         d.file('test_pkg.dart', '''
 void main() {
   final a = 10; // Unused.
@@ -91,7 +160,7 @@
     await expectValidation(
       allOf([
         contains('`dart analyze` found the following issue(s):'),
-        contains('Analyzing myapp...'),
+        contains('Analyzing lib, test, pubspec.yaml...'),
         contains('info -'),
         contains("The value of the local variable 'a' isn't used"),
         contains('Package has 1 warning.')
diff --git a/tool/extract_all_pub_dev.dart b/tool/extract_all_pub_dev.dart
index 474c897..ca90cd0 100644
--- a/tool/extract_all_pub_dev.dart
+++ b/tool/extract_all_pub_dev.dart
@@ -20,13 +20,19 @@
 
 Future<List<String>> allPackageNames() async {
   var nextUrl = Uri.https('pub.dev', 'api/packages?compact=1');
-  final result = json.decode(await httpClient.read(nextUrl));
+  final request = http.Request('GET', nextUrl);
+  request.attachMetadataHeaders();
+  final response = await globalHttpClient.fetch(request);
+  final result = json.decode(response.body);
   return List<String>.from(result['packages']);
 }
 
 Future<List<String>> versionArchiveUrls(String packageName) async {
   final url = Uri.https('pub.dev', 'api/packages/$packageName');
-  final result = json.decode(await httpClient.read(url));
+  final request = http.Request('GET', url);
+  request.attachMetadataHeaders();
+  final response = await globalHttpClient.fetch(request);
+  final result = json.decode(response.body);
   return List<String>.from(result['versions'].map((v) => v['archive_url']));
 }
 
@@ -81,8 +87,10 @@
               log.message('downloading $archiveUrl');
               http.StreamedResponse response;
               try {
-                response = await httpClient
-                    .send(http.Request('GET', Uri.parse(archiveUrl)));
+                final archiveUri = Uri.parse(archiveUrl);
+                final request = http.Request('GET', archiveUri);
+                request.attachMetadataHeaders();
+                response = await globalHttpClient.fetchAsStream(request);
                 await extractTarGz(response.stream, tempDir);
                 log.message('Extracted $archiveUrl');
               } catch (e) {
diff --git a/vendor.yaml b/vendor.yaml
new file mode 100644
index 0000000..27db379
--- /dev/null
+++ b/vendor.yaml
@@ -0,0 +1,10 @@
+import_rewrites:
+  oauth2: oauth2
+  tar: tar
+vendored_dependencies:
+  oauth2:
+    package: oauth2
+    version: 2.0.1
+  tar:
+    package: tar
+    version: 0.5.6