Skip to content

Support 3rd-party authentication via bearer token, also other patches #2167

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions lib/src/bearer_token_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) 2015, 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:io';
import 'package:http/http.dart';
import 'package:pub/src/exceptions.dart';
import 'package:pub/src/http.dart';

/// An HTTP [BaseClient] implementation that adds an `authorization`
/// header containing the given `Bearer` [getToken] to each request.
class BearerTokenClient extends BaseClient {
/// The underlying [BaseClient] to use to send requests.
final BaseClient httpClient;

/// A callback that takes a server response (potentially `null`),
/// and returns a token to be sent with all requests.
/// If the server returns a 401, this function will be invoked
/// and used to prompt the user for a new token.
///
/// All requests will have the `authorization` header set to
/// `'Bearer $token'`.
final FutureOr<String> Function(String) getToken;

String _token;

BearerTokenClient(this.httpClient, this._token, this.getToken);

@override
void close() {
httpClient.close();
super.close();
}

@override
Future<StreamedResponse> send(BaseRequest request) {
if (_token != null) {
request.headers[HttpHeaders.authorizationHeader] = 'Bearer $_token';
}

return httpClient.send(request).then((response) async {
if (response.statusCode != HttpStatus.unauthorized) {
return response;
} else {
// If we get a 401, print the reply from the server, so
// that servers can give custom instructions to users on
// how to sign in.
String serverResponse;

try {
handleJsonError(await Response.fromStream(response));
} on ApplicationException catch (e) {
serverResponse = e.message;
}

_token = null; // Remove the current token.
_token = await getToken(serverResponse); // Prompt again.
request.headers[HttpHeaders.authorizationHeader] = 'Bearer $_token';
return httpClient.send(request);
}
});
}
}
4 changes: 3 additions & 1 deletion lib/src/command/lish.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ class LishCommand extends PubCommand {
return log.progress('Uploading', () async {
// TODO(nweiz): Cloud Storage can provide an XML-formatted error. We
// should report that error and exit.
var newUri = server.resolve("/api/packages/versions/new");
var newUri = server.replace(
pathSegments: server.pathSegments
.followedBy(['api', 'packages', 'versions', 'new']));
var response = await client.get(newUri, headers: pubApiHeaders);
var parameters = parseJsonResponse(response);

Expand Down
11 changes: 6 additions & 5 deletions lib/src/command/uploader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,16 @@ class UploaderCommand extends PubCommand {
var uploader = rest[0];
return oauth2.withClient(cache, (client) {
if (command == 'add') {
var url = server.resolve("/api/packages/"
"${Uri.encodeComponent(package)}/uploaders");
var url = server.replace(
pathSegments: server.pathSegments
.followedBy(['api', 'packages', package, 'uploaders']));
return client
.post(url, headers: pubApiHeaders, body: {"email": uploader});
} else {
// command == 'remove'
var url = server.resolve("/api/packages/"
"${Uri.encodeComponent(package)}/uploaders/"
"${Uri.encodeComponent(uploader)}");
var url = server.replace(
pathSegments: server.pathSegments.followedBy(
['api', 'package', package, 'uploaders', uploader]));
return client.delete(url, headers: pubApiHeaders);
}
});
Expand Down
16 changes: 16 additions & 0 deletions lib/src/io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,22 @@ Future<bool> confirm(String message) {
return streamFirst(_stdinLines).then(RegExp(r"^[yY]").hasMatch);
}

/// Displays a message and reads a text response from the user.
///
/// Returns a [Future] that completes with the next line from stdin.
///
/// This will automatically append ":" to the message, so [message]
/// should just be a fragment like, "Enter your name".
Future<String> prompt(String message) {
log.fine('Showing prompt: $message');
if (runningFromTest) {
log.message("$message: ");
} else {
stdout.write(log.format("$message: "));
}
return streamFirst(_stdinLines);
}

/// Flushes the stdout and stderr streams, then exits the program with the given
/// status code.
///
Expand Down
102 changes: 97 additions & 5 deletions lib/src/oauth2.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:http/http.dart' as http;
import 'package:oauth2/oauth2.dart';
import 'package:path/path.dart' as path;
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;

import 'bearer_token_client.dart';
import 'http.dart';
import 'io.dart';
import 'log.dart' as log;
Expand Down Expand Up @@ -64,6 +66,12 @@ final _scopes = ['openid', 'https://www.googleapis.com/auth/userinfo.email'];
/// cache.
Credentials _credentials;

/// An in-memory cache of the user's bearer tokens.
///
/// This should always be the same as the tokens file stored in the system
/// cache.
Map<String, String> _tokens;

/// Delete the cached credentials, if they exist.
void _clearCredentials(SystemCache cache) {
_credentials = null;
Expand All @@ -90,12 +98,16 @@ void logout(SystemCache cache) {
/// This takes care of loading and saving the client's credentials, as well as
/// prompting the user for their authorization. It will also re-authorize and
/// re-run [fn] if a recoverable authorization error is detected.
Future<T> withClient<T>(SystemCache cache, Future<T> fn(Client client)) {
Future<T> withClient<T>(
SystemCache cache, Future<T> fn(http.BaseClient client)) {
return _getClient(cache).then((client) {
return fn(client).whenComplete(() {
client.close();
// Be sure to save the credentials even when an error happens.
_saveCredentials(cache, client.credentials);
if (client is Client) {
// Be sure to save the credentials even when an error happens.
// Note: this is only performed for the pub.dartlang.org client.
_saveCredentials(cache, client.credentials);
}
});
}).catchError((error) {
if (error is ExpirationException) {
Expand All @@ -120,7 +132,35 @@ Future<T> withClient<T>(SystemCache cache, Future<T> fn(Client client)) {
///
/// If saved credentials are available, those are used; otherwise, the user is
/// prompted to authorize the pub client.
Future<Client> _getClient(SystemCache cache) async {
Future<http.BaseClient> _getClient(SystemCache cache) async {
// For any server other than pub.dartlang.org and pub.dev, we will
// use $PUB_CACHE/tokens.json
var pubHostedUrl =
Platform.environment['PUB_HOSTED_URL'] ?? 'https://pub.dev';
if (!['https://pub.dartlang.org', 'https://pub.dev'].contains(pubHostedUrl)) {
// Pub will default to searching for an OAuth2 token in
// $PUB_CACHE/credentials.json.
//
// However, if $PUB_HOSTED_URL is contained within $PUB_CACHE/tokens.json,
// then instead opt for an HTTP client that sends the provided token
// in the Authorization header.
var tokens = _loadTokens(cache);
var tokensFile = _tokensFile(cache);
return BearerTokenClient(httpClient, tokens[pubHostedUrl],
(serverReply) async {
// If there is no entry for the given server, prompt the user for one.
log.message('Your \$PUB_HOSTED_URL is "$pubHostedUrl", but "$tokensFile" '
'contains no entry for that URL.');
if (serverReply != null) {
log.warning(serverReply);
}
var token = await prompt('Enter your token for "$pubHostedUrl"');
// Save the new credentials.
_saveTokens(cache, tokens..[pubHostedUrl] = token);
return token;
});
}

var credentials = _loadCredentials(cache);
if (credentials == null) return await _authorize();

Expand Down Expand Up @@ -163,6 +203,43 @@ Credentials _loadCredentials(SystemCache cache) {
}
}

/// Loads the user's stored bearer tokens from the in-memory cache or the
/// filesystem if possible.
///
/// If the credentials can't be loaded for any reason, the returned [Future]
/// completes to `{}`.
Map<String, String> _loadTokens(SystemCache cache) {
String path;

try {
if (_tokens != null) return _tokens;

path = _tokensFile(cache);
if (!fileExists(path)) return {};

var data = json.decode(readTextFile(path));
if (data is Map<String, dynamic>) {
// So that format errors can be caught as early as possible,
// eagerly iterate through and cast the set of tokens, rather
// than using a lazy alternative.
return Map.fromEntries(
data.entries.map((e) => MapEntry(e.key, e.value as String)));
} else {
log.error(
'The format of "$path" is incorrect. It must be a map of string keys to string values.');
return {};
}
} on CastError {
var sourceOfError = path == null ? '' : '"$path"';
log.error('The format of $sourceOfError is incorrect. '
'It must be a map of string keys to string values, '
'but at least one key or value was not a string.');
return {};
} catch (e) {
return {};
}
}

/// Save the user's OAuth2 credentials to the in-memory cache and the
/// filesystem.
void _saveCredentials(SystemCache cache, Credentials credentials) {
Expand All @@ -173,10 +250,25 @@ void _saveCredentials(SystemCache cache, Credentials credentials) {
writeTextFile(credentialsPath, credentials.toJson(), dontLogContents: true);
}

/// Save the user's bearer tokens to the in-memory cache and the
/// filesystem.
void _saveTokens(SystemCache cache, Map<String, String> tokens) {
log.fine('Saving bearer tokens.');
_tokens = tokens;
var encoder = JsonEncoder.withIndent(' ');
var tokensPath = _tokensFile(cache);
ensureDir(path.dirname(tokensPath));
writeTextFile(tokensPath, encoder.convert(tokens), dontLogContents: true);
}

/// The path to the file in which the user's OAuth2 credentials are stored.
String _credentialsFile(SystemCache cache) =>
path.join(cache.rootDir, 'credentials.json');

/// The path to the file in which the user's third-party Bearer tokens are stored.
String _tokensFile(SystemCache cache) =>
path.join(cache.rootDir, 'tokens.json');

/// Gets the user to authorize pub as a client of pub.dartlang.org via oauth2.
///
/// Returns a Future that completes to a fully-authorized [Client].
Expand Down
10 changes: 7 additions & 3 deletions lib/src/source/hosted.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import '../exceptions.dart';
import '../http.dart';
import '../io.dart';
import '../log.dart' as log;
import '../oauth2.dart';
import '../package.dart';
import '../package_name.dart';
import '../pubspec.dart';
Expand Down Expand Up @@ -157,7 +158,8 @@ class BoundHostedSource extends CachedSource {

String body;
try {
body = await httpClient.read(url, headers: pubApiHeaders);
body = await withClient(systemCache,
(httpClient) => httpClient.read(url, headers: pubApiHeaders));
} catch (error, stackTrace) {
var parsed = source._parseDescription(ref.description);
_throwFriendlyError(error, stackTrace, parsed.first, parsed.last);
Expand Down Expand Up @@ -198,7 +200,8 @@ class BoundHostedSource extends CachedSource {
log.io("Describe package at $url.");
Map<String, dynamic> version;
try {
version = jsonDecode(await httpClient.read(url, headers: pubApiHeaders));
version = jsonDecode(await withClient(systemCache,
(httpClient) => httpClient.read(url, headers: pubApiHeaders)));
} catch (error, stackTrace) {
var parsed = source._parseDescription(id.description);
_throwFriendlyError(error, stackTrace, id.name, parsed.last);
Expand Down Expand Up @@ -319,7 +322,8 @@ class BoundHostedSource extends CachedSource {

// Download and extract the archive to a temp directory.
var tempDir = systemCache.createTempDir();
var response = await httpClient.send(http.Request("GET", url));
var response = await withClient(
systemCache, (httpClient) => httpClient.send(http.Request("GET", url)));
await extractTarGz(response.stream, tempDir);

// Remove the existing directory if it exists. This will happen if
Expand Down