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