blob: 6ea254dced0a48f7593258750441cf34d58b0643 [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:collection';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'handle_access_token_response.dart';
import 'parameters.dart';
import 'utils.dart';
/// 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 =>
expiration != null && new 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> getParameters(MediaType mediaType, String body)})
: scopes = new 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) {
validate(condition, message) {
if (condition) return;
throw new FormatException(
"Failed to load credentials: $message.\n\n$json");
}
var parsed;
try {
parsed = jsonDecode(json);
} on FormatException {
validate(false, 'invalid JSON');
}
validate(parsed is Map, 'was not a JSON 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']) {
var value = parsed[stringField];
validate(value == null || value is String,
'field "$stringField" was not a string, was "$value"');
}
var scopes = parsed['scopes'];
validate(scopes == null || scopes is List,
'field "scopes" was not a list, was "$scopes"');
var tokenEndpoint = parsed['tokenEndpoint'];
if (tokenEndpoint != null) {
tokenEndpoint = Uri.parse(tokenEndpoint);
}
var expiration = parsed['expiration'];
if (expiration != null) {
validate(expiration is int,
'field "expiration" was not an int, was "$expiration"');
expiration = new DateTime.fromMillisecondsSinceEpoch(expiration);
}
return new Credentials(parsed['accessToken'],
refreshToken: parsed['refreshToken'],
idToken: parsed['idToken'],
tokenEndpoint: tokenEndpoint,
scopes: (scopes as List).map((scope) => scope as String),
expiration: expiration);
}
/// 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 == null ? null : tokenEndpoint.toString(),
'scopes': scopes,
'expiration':
expiration == null ? null : 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
/// [FormatError] 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();
if (scopes == null) scopes = [];
if (httpClient == null) httpClient = new http.Client();
if (identifier == null && secret != null) {
throw new ArgumentError("secret may not be passed without identifier.");
}
var startTime = new DateTime.now();
if (refreshToken == null) {
throw new StateError("Can't refresh credentials without a refresh "
"token.");
} else if (tokenEndpoint == null) {
throw new StateError("Can't refresh credentials without a token "
"endpoint.");
}
var headers = <String, String>{};
var 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;
}
var response =
await httpClient.post(tokenEndpoint, headers: headers, body: body);
var credentials = await 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 new Credentials(credentials.accessToken,
refreshToken: this.refreshToken,
idToken: credentials.idToken,
tokenEndpoint: credentials.tokenEndpoint,
scopes: credentials.scopes,
expiration: credentials.expiration);
}
}