blob: 9a116ef437a8e9035ba9c49b13499a517fdfef47 [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:convert';
import 'dart:io';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:http/retry.dart';
import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p;
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 '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';
/// 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]: 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.
///
/// `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]: https://developers.google.com/accounts/docs/OAuth2WebServer#offline
final _authorizationEndpoint =
Uri.parse('https://accounts.google.com/o/oauth2/auth?access_type=offline'
'&approval_prompt=force');
/// 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 {
final tokenEndpoint = Platform.environment['_PUB_TEST_TOKEN_ENDPOINT'];
if (tokenEndpoint != null) {
return Uri.parse(tokenEndpoint);
} else {
return _tokenEndpoint;
}
}
final _tokenEndpoint = Uri.parse('https://accounts.google.com/o/oauth2/token');
/// 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', 'https://www.googleapis.com/auth/userinfo.email'];
/// 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() {
_credentials = null;
final credentialsFile = _credentialsFile();
if (credentialsFile != null && entryExists(credentialsFile)) {
deleteEntry(credentialsFile);
}
}
/// Try to delete the cached credentials.
void logout() {
final credentialsFile = _credentialsFile();
if (credentialsFile != null && entryExists(credentialsFile)) {
log.message('Logging out of pub.dev.');
log.message('Deleting $credentialsFile');
_clearCredentials();
} else {
log.message(
'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>(Future<T> Function(http.Client) fn) {
return _getClient().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(client.credentials);
});
}).catchError((Object error) {
if (error is _ExpirationException) {
log.error("Pub's authorization to upload packages has expired and "
"can't be automatically refreshed.");
return withClient(fn);
} else if (error is _AuthorizationException) {
var message = 'OAuth2 authorization failed';
if (error.description != null) {
message = '$message (${error.description})';
}
log.error('$message.');
_clearCredentials();
return withClient(fn);
} else {
// ignore: only_throw_errors
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() async {
final credentials = loadCredentials();
if (credentials == null) return await _authorize();
final client = _Client(
credentials,
identifier: _identifier,
secret: _secret,
// Google's OAuth2 API doesn't support basic auth.
basicAuth: false,
httpClient: _retryHttpClient,
);
_saveCredentials(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() {
log.fine('Loading OAuth2 credentials.');
try {
if (_credentials != null) return _credentials;
final path = _credentialsFile();
if (path == null || !fileExists(path)) return null;
final 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) {
// Don't print the error message itself here. I might be leaking data about
// credentials.
log.error('Warning: could not load the saved OAuth2 credentials.\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(Credentials credentials) {
log.fine('Saving OAuth2 credentials.');
_credentials = credentials;
final credentialsPath = _credentialsFile();
if (credentialsPath != null) {
ensureDir(p.dirname(credentialsPath));
writeTextFile(credentialsPath, credentials.toJson(), dontLogContents: true);
}
}
/// The path to the file in which the user's OAuth2 credentials are stored.
///
/// Returns `null` if there is no good place for the file.
String? _credentialsFile() {
final configDir = dartConfigDir;
return configDir == null ? null : p.join(configDir, 'pub-credentials.json');
}
/// Gets the user to authorize pub as a client of pub.dev via oauth2.
///
/// Returns a Future that completes to a fully-authorized [_Client].
Future<_Client> _authorize() async {
final 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.
final completer = Completer<_Client>();
final 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...');
final queryString = request.url.query;
// Closing the server here is safe, since it will wait until the response
// is sent to actually shut down.
server.close();
completer
.complete(grant.handleAuthorizationResponse(queryToMap(queryString)));
return shelf.Response.found('https://pub.dev/authorized');
});
final authUrl = grant.getAuthorizationUrl(
Uri.parse('http://localhost:${server.port}'),
scopes: _scopes,
);
log.message(
'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...');
final 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 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);
}
// The following code originates in package:oauth2.
// TODO(sigurdm): simplify to only do what we need.
/// 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;
final scopeList = scopes?.toList() ?? <String>[];
final codeChallenge = base64Url
.encode(sha256.convert(ascii.encode(_codeVerifier)).bytes)
.replaceAll('=', '');
_redirectEndpoint = redirect;
_scopes = scopeList;
_stateString = state;
final 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')) {
final description = parameters['error_description'];
final uriString = parameters['error_uri'];
final 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 {
final startTime = DateTime.now();
final headers = <String, String>{};
final body = {
'grant_type': 'authorization_code',
'code': authorizationCode,
'redirect_uri': _redirectEndpoint.toString(),
'code_verifier': _codeVerifier,
};
final 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;
}
final response =
await _httpClient!.post(tokenEndpoint, headers: headers, body: body);
final 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.
enum _State {
initial('initial'),
awaitingResponse('awaiting response'),
finished('finished');
final String _name;
const _State(this._name);
@override
String toString() => _name;
}
/// 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.';
}
}
/// 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}';
final 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;
}
final challenge = challenges
.firstWhereOrNull((challenge) => challenge.scheme == 'bearer');
if (challenge == null) return response;
final 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;
}
}
/// 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 {
final 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, String 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']) {
final value = parsed[stringField];
validate(
value == null || value is String,
'field "$stringField" was not a string, was "$value"',
);
}
final scopes = parsed['scopes'];
validate(
scopes == null || scopes is List,
'field "scopes" was not a list, was "$scopes"',
);
final 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.');
}
final startTime = DateTime.now();
final 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.');
}
final headers = <String, String>{};
final 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;
}
final response =
await httpClient.post(tokenEndpoint, headers: headers, body: body);
final 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,
);
}
}
/// 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.";
}
/// 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);
}
final contentTypeString = response.headers['content-type'];
if (contentTypeString == null) {
throw const FormatException('Missing Content-Type string.');
}
final 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']) {
final value = parameters[name];
if (value != null && value is! String) {
throw FormatException(
'parameter "$name" was not a string, was "$value"',
);
}
}
final scope = parameters['scope'] as String?;
if (scope != null) scopes = scope.split(delimiter);
final 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 = '';
final 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}');
}
final contentTypeString = response.headers['content-type'];
final contentType =
contentTypeString == null ? null : MediaType.parse(contentTypeString);
final 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']) {
final value = parameters[name];
if (value != null && value is! String) {
throw FormatException('parameter "$name" was not a string, was "$value"');
}
}
final uriString = parameters['error_uri'] as String?;
final uri = uriString == null ? null : Uri.parse(uriString);
final description = parameters['error_description'] as String?;
throw _AuthorizationException(
parameters['error'] as String,
description,
uri,
);
}
/// 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"',
);
}
final untypedParameters = jsonDecode(body);
if (untypedParameters is Map<String, dynamic>) {
return untypedParameters;
}
throw FormatException('Parameters must be a map, was "$untypedParameters"');
}
/// 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) {
final userPass = '${Uri.encodeFull(identifier)}:${Uri.encodeFull(secret)}';
return 'Basic ${base64Encode(ascii.encode(userPass))}';
}