blob: b51fdfbc750f6b251488ab85548ecf28fa0a4777 [file] [log] [blame]
// 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:io';
import 'package:collection/collection.dart' show IterableExtension;
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;
import 'http.dart';
import 'io.dart';
import 'log.dart' as log;
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(
when: (response) => response.statusCode >= 500,
whenError: (e, _) => isHttpIOException(e),
/// The pub client's OAuth2 identifier.
const _identifier = '818368855108-8grd2eg9tj9f38os6f1urbcvsq399u8n.apps.'
/// The pub client's OAuth2 secret.
/// 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]:
final _oidcDiscoveryDocumentEndpoint =
Uri.https('', '/.well-known/openid-configuration');
/// The URL to which the user will be directed to authorize the pub client to
/// get an OAuth2 access token.
/// `access_type=offline` and `approval_prompt=force` ensures that we always get
/// a refresh token from the server. See the [Google OAuth2 documentation][].
/// [Google OAuth2 documentation]:
final _authorizationEndpoint =
/// The URL from which the pub client will request an access token once it's
/// been authorized by the user.
/// This can be controlled externally by setting the `_PUB_TEST_TOKEN_ENDPOINT`
/// environment variable.
Uri get tokenEndpoint {
var tokenEndpoint = Platform.environment['_PUB_TEST_TOKEN_ENDPOINT'];
if (tokenEndpoint != null) {
return Uri.parse(tokenEndpoint);
} else {
return _tokenEndpoint;
final _tokenEndpoint = Uri.parse('');
/// The OAuth2 scopes that the pub client needs.
/// Currently the client only needs the user's email so that the server can
/// verify their identity.
final _scopes = ['openid', ''];
/// An in-memory cache of the user's OAuth2 credentials.
/// This should always be the same as the credentials file stored in the system
/// cache.
Credentials? _credentials;
/// Delete the cached credentials, if they exist.
void _clearCredentials(SystemCache cache) {
_credentials = null;
var credentialsFile = _credentialsFile(cache);
if (credentialsFile != null && entryExists(credentialsFile)) {
/// Try to delete the cached credentials.
void logout(SystemCache cache) {
var credentialsFile = _credentialsFile(cache);
if (credentialsFile != null && entryExists(credentialsFile)) {
log.message('Logging out of');
log.message('Deleting $credentialsFile');
// Test if we also have a legacy credentials file.
final legacyCredentialsFile = _legacyCredentialsFile(cache);
if (entryExists(legacyCredentialsFile)) {
log.message('Also deleting legacy credentials at $legacyCredentialsFile');
} else {
'No existing credentials file $credentialsFile. Cannot log out.',
/// Asynchronously passes an OAuth2 [Client] to [fn].
/// Does not close the client, since that would close the shared client. It must
/// be closed elsewhere.
/// 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> Function(Client) fn) {
return _getClient(cache).then((client) {
return fn(client).whenComplete(() {
// TODO(sigurdm): refactor the http subsystem, so we can close [client]
// here.
// Be sure to save the credentials even when an error happens.
_saveCredentials(cache, client.credentials);
}).catchError((error) {
if (error is ExpirationException) {
log.error("Pub's authorization to upload packages has expired and "
"can't be automatically refreshed.");
return withClient(cache, fn);
} else if (error is AuthorizationException) {
var message = 'OAuth2 authorization failed';
if (error.description != null) {
message = '$message (${error.description})';
return withClient(cache, fn);
} else {
throw error;
/// Gets a new OAuth2 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 {
var credentials = loadCredentials(cache);
if (credentials == null) return await _authorize();
var client = Client(
identifier: _identifier,
secret: _secret,
// Google's OAuth2 API doesn't support basic auth.
basicAuth: false,
httpClient: _retryHttpClient,
_saveCredentials(cache, client.credentials);
return client;
/// Loads the user's OAuth2 credentials 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 `null`.
Credentials? loadCredentials(SystemCache cache) {
log.fine('Loading OAuth2 credentials.');
try {
if (_credentials != null) return _credentials;
var path = _credentialsFile(cache);
if (path == null || !fileExists(path)) return null;
var credentials = Credentials.fromJson(readTextFile(path));
if (credentials.isExpired && !credentials.canRefresh) {
log.error("Pub's authorization to upload packages has expired and "
"can't be automatically refreshed.");
return null; // null means re-authorize.
return credentials;
} catch (e) {
log.error('Warning: could not load the saved OAuth2 credentials: $e\n'
'Obtaining new credentials...');
return null; // null means re-authorize.
/// Save the user's OAuth2 credentials to the in-memory cache and the
/// filesystem.
void _saveCredentials(SystemCache cache, Credentials credentials) {
log.fine('Saving OAuth2 credentials.');
_credentials = credentials;
var credentialsPath = _credentialsFile(cache);
if (credentialsPath != null) {
writeTextFile(credentialsPath, credentials.toJson(), dontLogContents: true);
/// The path to the file in which the user's OAuth2 credentials are stored.
/// This used to be PUB_CACHE/credentials.json. But the pub cache is not the
/// best place for storing secrets, as it might be shared.
/// To provide backwards compatibility we use the legacy file if only it exists.
/// Returns `null` if there is no good place for the file.
String? _credentialsFile(SystemCache cache) {
final configDir = dartConfigDir;
final newCredentialsFile =
configDir == null ? null : path.join(configDir, 'pub-credentials.json');
var file = [
if (newCredentialsFile != null) newCredentialsFile,
return file ?? newCredentialsFile;
String _legacyCredentialsFile(SystemCache cache) {
return path.join(cache.rootDir, 'credentials.json');
/// Gets the user to authorize pub as a client of via oauth2.
/// Returns a Future that completes to a fully-authorized [Client].
Future<Client> _authorize() async {
var grant = AuthorizationCodeGrant(
_identifier, _authorizationEndpoint, tokenEndpoint,
secret: _secret,
// Google's OAuth2 API doesn't support basic auth.
basicAuth: false,
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
// the code is received.
var completer = Completer();
var server = await bindServer('localhost', 0);
shelf_io.serveRequests(server, (request) {
if (request.url.path.isNotEmpty) {
return shelf.Response.notFound('Invalid URI.');
log.message('Authorization received, processing...');
var queryString = request.url.query;
// Closing the server here is safe, since it will wait until the response
// is sent to actually shut down.
return shelf.Response.found('');
var authUrl = grant.getAuthorizationUrl(
scopes: _scopes,
'Pub needs your authorization to upload packages on your behalf.\n'
'In a web browser, go to $authUrl\n'
'Then click "Allow access".\n\n'
'Waiting for your authorization...');
var client = await completer.future;
log.message('Successfully authorized.\n');
return client;
/// Fetches Google's OpenID Connect Discovery document and parses the JSON
/// response body into a [Map].
/// See
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);