blob: 785473bbeaa7c8ed2227e6327ef7c00a8fd12d3d [file] [log] [blame]
// Copyright (c) 2014, 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:convert';
import 'package:http/http.dart' show BaseRequest, Client, StreamedResponse;
import 'package:http_parser/http_parser.dart';
import 'access_token.dart';
import 'exceptions.dart';
import 'http_client_base.dart';
import 'known_uris.dart';
/// Due to differences of clock speed, network latency, etc. we
/// will shorten expiry dates by 20 seconds.
const maxExpectedTimeDiffInSeconds = 20;
AccessToken parseAccessToken(Map<String, dynamic> jsonMap) {
final tokenType = jsonMap['token_type'];
final accessToken = jsonMap['access_token'];
final expiresIn = jsonMap['expires_in'];
if (accessToken is! String || expiresIn is! int || tokenType != 'Bearer') {
throw ServerRequestFailedException(
'Failed to exchange authorization code. Invalid server response.',
responseContent: jsonMap,
);
}
return AccessToken('Bearer', accessToken, expiryDate(expiresIn));
}
/// Constructs a [DateTime] which is [seconds] seconds from now with
/// an offset of [maxExpectedTimeDiffInSeconds]. Result is UTC time.
DateTime expiryDate(int seconds) => DateTime.now()
.toUtc()
.add(Duration(seconds: seconds - maxExpectedTimeDiffInSeconds));
/// Constant for the 'application/x-www-form-urlencoded' content type
const _contentTypeUrlEncoded =
'application/x-www-form-urlencoded; charset=utf-8';
Future<Map<String, dynamic>> _readJsonMapFromResponse(
StreamedResponse response,
) async {
await _expectJsonResponse(response);
Object? jsonValue;
final bytes = await response.stream.toBytes();
late String string;
try {
string = utf8.decode(bytes);
} on FormatException catch (e) {
throw ServerRequestFailedException(
'The response was not valid UTF-8. '
'$e',
statusCode: response.statusCode,
responseContent: bytes,
);
}
try {
jsonValue = jsonDecode(string);
} on FormatException catch (e) {
throw ServerRequestFailedException(
'Could not decode the response as JSON. '
'$e',
statusCode: response.statusCode,
responseContent: string,
);
}
if (jsonValue is! Map<String, dynamic>) {
throw ServerRequestFailedException(
'The returned JSON response was not a Map.',
statusCode: response.statusCode,
responseContent: jsonValue,
);
}
return jsonValue;
}
extension ClientExtensions on Client {
Future<Map<String, dynamic>> requestJson(
BaseRequest request,
String errorHeader,
) async {
final response = await send(request);
final jsonMap = await _readJsonMapFromResponse(response);
if (response.statusCode != 200) {
final error = _errorStringFromJsonResponse(jsonMap);
final message = [
errorHeader,
if (error != null) error,
].join(' ');
throw ServerRequestFailedException(
message,
statusCode: response.statusCode,
responseContent: jsonMap,
);
}
return jsonMap;
}
Future<Map<String, dynamic>> oauthTokenRequest(
Map<String, String> postValues,
) async {
final body = Stream<List<int>>.value(
ascii.encode(
postValues.entries
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
.join('&'),
),
);
final request = RequestImpl('POST', googleOauth2TokenEndpoint, body)
..headers['content-type'] = _contentTypeUrlEncoded;
return requestJson(request, 'Failed to obtain access credentials.');
}
}
/// Returns an error string for [json] if it contains error data in keys
/// `error` and `error_description`.
///
/// Otherwise, returns `null`.
String? _errorStringFromJsonResponse(Map<String, dynamic> json) {
final error = json['error'];
final values = [
if (error != null) 'Error: $error',
json['error_description'],
].where((element) => element != null).join(' ');
if (values.isEmpty) return null;
return values;
}
Future<void> _expectJsonResponse(StreamedResponse response) async {
final contentType = response.headers['content-type'];
if (!_isJson(contentType)) {
String? body;
try {
body = await response.stream.bytesToString();
} catch (_) {
/// We're already going to throw below
}
final message = contentType == null
? 'Server responded without a content type header.'
: 'Server responded with invalid content type: $contentType. ';
throw ServerRequestFailedException(
'$message Expected a JSON response.',
statusCode: response.statusCode,
responseContent: body,
);
}
}
/// Follows https://mimesniff.spec.whatwg.org/#json-mime-type
bool _isJson(String? contentType) {
if (contentType == null) return false;
final mediaType = MediaType.parse(contentType);
if (mediaType.mimeType == 'application/json') return true;
if (mediaType.mimeType == 'text/json') return true;
return mediaType.subtype.endsWith('+json');
}