blob: e0858d8b3e263494ca265de56c54e954b5fed713 [file] [log] [blame] [edit]
// Copyright 2021 Google LLC
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file or at
// https://developers.google.com/open-source/licenses/bsd
import 'dart:convert';
import 'package:http/http.dart' show Client, Request, StreamedResponse;
import 'package:http_parser/http_parser.dart';
import 'access_token.dart';
import 'auth_endpoints.dart';
import 'exceptions.dart';
/// Due to differences of clock speed, network latency, etc. we
/// will shorten expiry dates by 20 seconds.
const maxExpectedTimeDiffInSeconds = 20;
/// The default universe domain for Google Cloud services.
///
/// The universe domain is the root domain for Google Cloud API endpoints.
/// The default is `googleapis.com`.
///
/// Trusted Partner Cloud (TPC) and Google Distributed Cloud (GDC) environments
/// may use a different universe domain.
const defaultUniverseDomain = 'googleapis.com';
AccessToken parseAccessToken(Map<String, dynamic> jsonMap) => switch (jsonMap) {
{
'token_type': 'Bearer',
'access_token': final String accessToken,
'expires_in': final int expiresIn,
} =>
AccessToken('Bearer', accessToken, expiryDate(expiresIn)),
_ => throw ServerRequestFailedException(
'Failed to exchange authorization code. Invalid server response.',
responseContent: jsonMap,
),
};
/// 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),
);
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(
String method,
Uri url,
String errorHeader, {
Map<String, String>? headers,
Object? body,
}) async {
final request = Request(method, url);
if (headers != null) {
request.headers.addAll(headers);
}
switch (body) {
case null:
break;
case final String body:
request.body = body;
case final List<int> body:
request.bodyBytes = body;
case final Map<String, String> body:
request.bodyFields = body;
case _:
throw ArgumentError.value(body, 'body', 'Unsupported body type.');
}
final response = await send(request);
final jsonMap = await _readJsonMapFromResponse(response);
if (response.statusCode != 200) {
final error = _errorStringFromJsonResponse(jsonMap);
final message = [errorHeader, ?error].join(' ');
throw ServerRequestFailedException(
message,
statusCode: response.statusCode,
responseContent: jsonMap,
);
}
return jsonMap;
}
Future<Map<String, dynamic>> oauthTokenRequest(
Map<String, String> postValues, {
required AuthEndpoints authEndpoints,
}) async => requestJson(
'POST',
authEndpoints.tokenEndpoint,
'Failed to obtain access credentials.',
body: postValues,
);
}
/// 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');
}