Skip to content

Commit

Permalink
Third party hosted pub registry authentication (#3007)
Browse files Browse the repository at this point in the history
Support to pub CLI to authenticate with third party hosted pub servers. 

Private repositories need authentication for both pulling and pushing data to it - unlike the previous behavior which only authenticated requests for pushing new packages.

CLI Interface
dart pub login                      # Will initiate login-flow for pub.dev
dart pub logout                     # Will delete credential.json

dart pub token list                # Will list servers for which a token exists
dart pub token add <hosted_url>    # Will prompt for a token for <hosted_url> and store both
dart pub token remove <hosted_url> # Will remove token for <hosted_url>
dart pub token remove --all        # Will delete all tokens stored
  • Loading branch information
themisir authored Sep 14, 2021
1 parent b1bedc5 commit bbdac80
Show file tree
Hide file tree
Showing 19 changed files with 881 additions and 45 deletions.
120 changes: 120 additions & 0 deletions lib/src/authentication/client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) 2021, 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.

// ignore_for_file: import_of_legacy_library_into_null_safe

import 'dart:io';

import 'package:http/http.dart' as http;

import '../http.dart';
import '../log.dart' as log;
import '../system_cache.dart';
import 'credential.dart';

/// This client authenticates requests by injecting `Authentication` header to
/// requests.
///
/// Requests to URLs not under [serverBaseUrl] will not be authenticated.
class _AuthenticatedClient extends http.BaseClient {
_AuthenticatedClient(this._inner, this.credential);

final http.BaseClient _inner;

/// Authentication scheme that could be used for authenticating requests.
final Credential credential;

@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
// Let's last time make sure that, we're allowed to use credential for this
// request.
//
// This check ensures that this client will only authenticate requests sent
// to given serverBaseUrl. Otherwise credential leaks might ocurr when
// archive_url hosted on 3rd party server that should not receive
// credentials of the first party.
if (credential.canAuthenticate(request.url.toString())) {
request.headers[HttpHeaders.authorizationHeader] =
await credential.getAuthorizationHeaderValue();
}
return _inner.send(request);
}

@override
void close() => _inner.close();
}

/// Invoke [fn] with a [http.Client] capable of authenticating against
/// [hostedUrl].
///
/// Importantly, requests to URLs not under [hostedUrl] will not be
/// authenticated.
Future<T> withAuthenticatedClient<T>(
SystemCache systemCache,
Uri hostedUrl,
Future<T> Function(http.Client) fn,
) async {
final credential = systemCache.tokenStore.findCredential(hostedUrl);
final http.Client client = credential == null
? httpClient
: _AuthenticatedClient(httpClient, credential);

try {
return await fn(client);
} on PubHttpException catch (error) {
if (error.response?.statusCode == 401 ||
error.response?.statusCode == 403) {
// TODO(themisir): Do we need to match error.response.request.url with
// the hostedUrl? Or at least we might need to log request.url to give
// user additional insights on what's happening.

String? serverMessage;

try {
final wwwAuthenticateHeaderValue =
error.response.headers[HttpHeaders.wwwAuthenticateHeader];
if (wwwAuthenticateHeaderValue != null) {
final parsedValue = HeaderValue.parse(wwwAuthenticateHeaderValue,
parameterSeparator: ',');
if (parsedValue.parameters['realm'] == 'pub') {
serverMessage = parsedValue.parameters['message'];
}
}
} catch (_) {
// Ignore errors might be caused when parsing invalid header values
}

if (error.response.statusCode == 401) {
if (systemCache.tokenStore.removeCredential(hostedUrl)) {
log.warning('Invalid token for $hostedUrl deleted.');
}

log.error(
'Authentication requested by hosted server at: $hostedUrl\n'
'You can use the following command to add token for the server:\n'
'\n pub token add $hostedUrl\n',
);
}
if (error.response.statusCode == 403) {
log.error(
'Insufficient permissions to the resource in hosted server at: '
'$hostedUrl\n'
'You can use the following command to update token for the server:\n'
'\n pub token add $hostedUrl\n',
);
}

if (serverMessage?.isNotEmpty == true) {
// Only allow printable ASCII, map anything else to whitespace, take
// at-most 1024 characters.
final truncatedMessage = String.fromCharCodes(serverMessage!.runes
.map((r) => 32 >= r && r <= 127 ? r : 32)
.take(1024));

log.error(truncatedMessage);
}
}
rethrow;
}
}
109 changes: 109 additions & 0 deletions lib/src/authentication/credential.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2021, 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.

// ignore_for_file: import_of_legacy_library_into_null_safe

import '../exceptions.dart';
import '../source/hosted.dart';

/// Token is a structure for storing authentication credentials for third-party
/// pub registries. A token holds registry [url], credential [kind] and [token]
/// itself.
///
/// Token could be serialized into and from JSON format structured like
/// this:
///
/// ```json
/// {
/// "url": "https://example.com/",
/// "token": "gjrjo7Tm2F0u64cTsECDq4jBNZYhco"
/// }
/// ```
class Credential {
/// Internal constructor that's only used by [fromJson].
Credential._internal({
required this.url,
required this.token,
required this.unknownFields,
});

/// Create a new [Credential].
Credential.token(this.url, this.token)
: unknownFields = const <String, dynamic>{};

/// Deserialize [json] into [Credential] type.
///
/// Throws [FormatException] if [json] is not a valid [Credential].
factory Credential.fromJson(Map<String, dynamic> json) {
if (json['url'] is! String) {
throw FormatException('Url is not provided for the credential');
}

final hostedUrl = validateAndNormalizeHostedUrl(json['url'] as String);

const knownKeys = {'url', 'token'};
final unknownFields = Map.fromEntries(
json.entries.where((kv) => !knownKeys.contains(kv.key)));

return Credential._internal(
url: hostedUrl,
token: json['token'] is String ? json['token'] as String : null,
unknownFields: unknownFields,
);
}

/// Server url which this token authenticates.
final Uri url;

/// Authentication token value
final String? token;

/// Unknown fields found in tokens.json. The fields might be created by the
/// future version of pub tool. We don't want to override them when using the
/// old SDK.
final Map<String, dynamic> unknownFields;

/// Serializes [Credential] into json format.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'url': url.toString(),
if (token != null) 'token': token,
...unknownFields,
};
}

/// Returns future that resolves "Authorization" header value used for
/// authenticating.
///
/// Throws [DataException] if credential is not valid.
// This method returns future to make sure in future we could use the
// [Credential] interface for OAuth2.0 authentication too - which requires
// token rotation (refresh) that's async job.
Future<String> getAuthorizationHeaderValue() {
if (!isValid()) {
throw DataException(
'Saved credential for $url pub repository is not supported by current '
'version of Dart SDK.',
);
}

return Future.value('Bearer $token');
}

/// Returns whether or not given [url] could be authenticated using this
/// credential.
bool canAuthenticate(String url) {
return _normalizeUrl(url).startsWith(_normalizeUrl(this.url.toString()));
}

/// Returns boolean indicates whether or not the credentials is valid.
///
/// This method might return `false` when a `tokens.json` file created by
/// future SDK used by pub tool from old SDK.
bool isValid() => token != null;

static String _normalizeUrl(String url) {
return (url.endsWith('/') ? url : '$url/').toLowerCase();
}
}
Loading

0 comments on commit bbdac80

Please sign in to comment.