| // 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: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: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 'path.dart'; | |
| import 'platform_info.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( | |
| 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; | |
| protectExistingFile(path); | |
| 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) { | |
| writeProtectedTextFile(credentialsPath, credentials.toJson()); | |
| } | |
| } | |
| /// 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] 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))}'; | |
| } |