| // 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 [http.Client] will automatically refresh its | |
| /// credentials when necessary. It's also possible to explicitly refresh them | |
| /// via [http.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 { | |
| var 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']) { | |
| 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']; | |
| 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.'); | |
| } | |
| var startTime = DateTime.now(); | |
| var 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.'); | |
| } | |
| 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 = 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); | |
| } | |
| } |