-
Notifications
You must be signed in to change notification settings - Fork 229
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Third party hosted pub registry authentication (#3007)
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
Showing
19 changed files
with
881 additions
and
45 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
Oops, something went wrong.