Bring googleapis_auth into the mono repo (#140)

Synced at https://github.com/dart-lang/googleapis_auth/commit/30c084b7650fbd3e52525a127e0e65fc528e85a8
Then fixed enabled lints, repo lint in pubspec, etc
diff --git a/googleapis_auth/CHANGELOG.md b/googleapis_auth/CHANGELOG.md
new file mode 100644
index 0000000..e0947c1
--- /dev/null
+++ b/googleapis_auth/CHANGELOG.md
@@ -0,0 +1,106 @@
+## 0.2.12+2-dev
+
+## 0.2.12+1
+
+ * Removed a `dart:async` import that isn't required for \>=Dart 2.1.
+ * Require \>=Dart 2.1.
+
+## 0.2.12
+ * Add `clientViaApplicationDefaultCredentials` for obtaining credentials using
+   [ADC](https://cloud.google.com/docs/authentication/production).
+
+## 0.2.11+1
+ * Fix 'multiple completer completion' bug in `ImplicitFlow`.
+
+## 0.2.11
+ * Add the `force` parameter to the `obtainAccessCredentialsViaUserConsent` API.
+
+## 0.2.10
+ * Look for GCE metadata host in environment under `$GCE_METADATA_HOST`.
+
+## 0.2.9
+ * Prepare for [Uint8List SDK breaking change](Prepare for Uint8List SDK breaking change).
+
+## 0.2.8
+
+* Initialize implicit browser flows statically, allowing multiple ImplicitFlow
+  objects to initialize without trying to load the gapi JavaScript library
+  multiple times.
+
+## 0.2.7
+
+ - Support for specifying desired `ResponseType`, allowing applications to
+   obtain an `id_token` using `ImplicitBrowserFlow`.
+
+## 0.2.6
+
+- Ignore script loading error after timeout for in-browser implicit login-flow.
+
+## 0.2.5+3
+
+- Support `package:http` `>=0.11.3+17 <0.13.0`.
+
+## 0.2.5+2
+
+* Support Dart 2.
+
+## 0.2.5+1
+
+* Switch all uppercase constants from `dart:convert` to lowercase.
+
+## 0.2.5
+
+* Add an optional `loginHint` parameter to browser oauth2 flow APIs which can be
+  used to specify a hint as to which user is being logged in.
+
+## 0.2.4
+
+* Added `id_token` to `AccessCredentials`
+
+* Migrated to Dart 2 `BigInt`.
+
+## 0.2.3+6
+
+- Fix async issue in oauth2 flow implementation
+
+## 0.2.3+5
+
+- Support the latest version of `crypto` package.
+
+## 0.2.3+4
+
+- Make package strong-mode compliant.
+
+## 0.2.3+3
+
+- Support package:crypto >= 0.9.2
+
+## 0.2.3+2
+
+- Use preferred "Metadata-Flavor" HTTP header in
+  `MetadataServerAuthorizationFlow` instead of the deprecated
+  "X-Google-Metadata-Request" header.
+
+## 0.2.3
+
+- Allow `ServiceAccountCredentials` constructors to take an optional
+  `user` argument to specify a user to impersonate.
+
+## 0.2.2
+
+- Allow `ServiceAccountCredentials.fromJson` to accept a `Map`.
+- Cleaned up `README.md`
+
+## 0.2.1
+- Added optional `force` and `immediate` arguments to `runHybridFlow`.
+
+## 0.2.0
+- Renamed `forceUserConsent` parameter to `immediate`.
+- Added `runHybridFlow` function to `auth_browser`, with corresponding
+  `HybridFlowResult` class.
+
+## 0.1.1
+- Add `clientViaApiKey` functions to `auth_io` ad `auth_browser`.
+
+## 0.1.0
+- First release.
diff --git a/googleapis_auth/LICENSE b/googleapis_auth/LICENSE
new file mode 100644
index 0000000..5c60afe
--- /dev/null
+++ b/googleapis_auth/LICENSE
@@ -0,0 +1,26 @@
+Copyright 2014, the Dart project authors. All rights reserved.
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of Google Inc. nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/googleapis_auth/README.md b/googleapis_auth/README.md
new file mode 100644
index 0000000..ea03d3f
--- /dev/null
+++ b/googleapis_auth/README.md
@@ -0,0 +1,305 @@
+## Googleapis Auth
+
+This package provides support for obtaining OAuth2 credentials to access
+Google APIs.
+
+This package also provides convenience functionality for:
+- obtaining authenticated HTTP clients
+- automatically refreshing OAuth2 credentials
+
+### Using this package
+
+Using this package requires creating a Google Cloud Project and obtaining
+application credentials for the specific application type.
+The steps required are:
+
+- Create a new Google Cloud Project on the
+  [Google Developers Console](https://console.developers.google.com)
+- Enable all APIs that the application will use on the
+  [Google Developers Console](https://console.developers.google.com)
+  (under DevConsole -> Project -> APIs & auth -> APIs)
+- Obtain application credentials for a specific application type on the
+  [Google Developers Console](https://console.developers.google.com)
+  (under DevConsole -> Project -> APIs & auth -> Credentials)
+- Use the `googleapis_auth` package to obtain access credentials /
+  obtain an authenticated HTTP client.
+
+Depending on the application type, there are different ways to achieve the
+third and fourth step. The following is a list of supported OAuth2 flows with
+a description of these two steps.
+
+
+#### Client-side Web Application
+
+For client-side only web applications a "Client ID" needs to be created
+(under DevConsole -> Project -> APIs & auth -> Credentials). When
+creating a new client ID, select the "Web application" type. For client-side
+only applications, no `Redirect URIs` are necessary. The `Javascript Origins`
+setting must be set to all URLs on which your application
+will be served (e.g. http://localhost:8080 for local testing).
+
+
+After the Client Id has been created, you can obtain access credentials via
+```dart
+import "package:googleapis_auth/auth_browser.dart";
+
+...
+
+var id = new ClientId("....apps.googleusercontent.com", null);
+var scopes = [...];
+
+// Initialize the browser oauth2 flow functionality.
+createImplicitBrowserFlow(id, scopes).then((BrowserOAuth2Flow flow) {
+  flow.obtainAccessCredentialsViaUserConsent()
+      .then((AccessCredentials credentials) {
+    // Credentials are available in [credentials].
+    ...
+    flow.close();
+  });
+});
+```
+
+or obtain an authenticated HTTP client via
+```dart
+import "package:googleapis_auth/auth_browser.dart";
+
+...
+
+var id = new ClientId("....apps.googleusercontent.com", null);
+var scopes = [...];
+
+// Initialize the browser oauth2 flow functionality.
+createImplicitBrowserFlow(id, scopes).then((BrowserOAuth2Flow flow) {
+  flow.clientViaUserConsent().then((AuthClient client) {
+    // Authenticated and auto refreshing client is available in [client].
+    ...
+    client.close();
+    flow.close();
+  });
+});
+```
+
+To prevent popup blockers from blocking the user authorization dialog, the
+methods `obtainAccessCredentialsViaUserConsent` and `clientViaUserConsent`
+should preferably only be called inside an event handler, since most browsers
+do not block popup windows created in response to a user interaction.
+
+The authenticated HTTP client can now access data on behalf a user for the
+requested oauth2 scopes.
+
+
+#### Installed/Console Application
+
+For installed/console applications a "Client ID" needs to be created
+(under DevConsole -> Project -> APIs & auth -> Credentials). When
+creating a new client ID, select the "Installed application -> Other" type.
+
+The redirect URIs for the automatic and manual flow will be configured
+automatically.
+
+After the Client Id has been created, you can obtain access credentials via
+```dart
+import "package:http/http.dart" as http;
+import "package:googleapis_auth/auth_io.dart";
+
+...
+
+var id = new ClientId("....apps.googleusercontent.com", "...");
+var scopes = [...];
+
+var client = new http.Client();
+obtainAccessCredentialsViaUserConsent(id, scopes, client, prompt)
+    .then((AccessCredentials credentials) {
+  // Access credentials are available in [credentials].
+  // ...
+  client.close();
+});
+
+void prompt(String url) {
+  print("Please go to the following URL and grant access:");
+  print("  => $url");
+  print("");
+}
+```
+
+or obtain an authenticated HTTP client via
+
+```dart
+import "package:googleapis_auth/auth_io.dart";
+
+...
+
+var id = new ClientId("....apps.googleusercontent.com", "...");
+var scopes = [...];
+
+clientViaUserConsent(id, scopes, prompt).then((AuthClient client) {
+  // Authenticated and auto refreshing client is available in [client].
+  // ...
+  client.close();
+});
+
+void prompt(String url) {
+  print("Please go to the following URL and grant access:");
+  print("  => $url");
+  print("");
+}
+```
+
+In case of misconfigured browsers/proxies or other issues, it is also possible
+to use a manual flow via `obtainAccessCredentialsViaUserConsentManual` and
+`clientViaUserConsentManual`. But in this case the `prompt` function needs to
+complete with a `Future<String>` which contains the "authorization code".
+The user obtains the "authorization code" (which is a string of characters) in
+a browser and needs to copy & paste it to the application. (The prompt function
+should block until it has gotten the "authorization code" from the user.)
+
+The authenticated HTTP client can now access data on behalf a user for the
+requested oauth2 scopes.
+
+
+#### Autonomous Application / Service Account
+
+If an application wants to act autonomously and access e.g. data from a Google
+Cloud Project, then a Service Account can be created. In this case no user
+authorization is involved.
+
+A service account can be created via the "Service account" application type
+when creating a new Client ID
+(under DevConsole -> Project -> APIs & auth -> Credentials). It will download
+a JSON document which contains a private RSA key. That private key is used for
+obtaining access credentials.
+
+After the service account was created, you can obtain access credentials via
+```dart
+import "package:http/http.dart" as http;
+import "package:googleapis_auth/auth_io.dart";
+
+var accountCredentials = new ServiceAccountCredentials.fromJson({
+  "private_key_id": "<please fill in>",
+  "private_key": "<please fill in>",
+  "client_email": "<please fill in>@developer.gserviceaccount.com",
+  "client_id": "<please fill in>.apps.googleusercontent.com",
+  "type": "service_account"
+});
+var scopes = [...];
+
+...
+
+var client = new http.Client();
+obtainAccessCredentialsViaServiceAccount(accountCredentials, scopes, client)
+    .then((AccessCredentials credentials) {
+  // Access credentials are available in [credentials].
+  // ...
+  client.close();
+});
+```
+
+or an authenticated HTTP client via
+
+```dart
+import "package:googleapis_auth/auth_io.dart";
+
+final accountCredentials = new ServiceAccountCredentials.fromJson({
+  "private_key_id": "<please fill in>",
+  "private_key": "<please fill in>",
+  "client_email": "<please fill in>@developer.gserviceaccount.com",
+  "client_id": "<please fill in>.apps.googleusercontent.com",
+  "type": "service_account"
+});
+var scopes = [...];
+
+...
+
+clientViaServiceAccount(accountCredentials, scopes).then((AuthClient client) {
+  // [client] is an authenticated HTTP client.
+  // ...
+  client.close();
+});
+```
+
+The authenticated HTTP client can now access APIs.
+
+##### Impersonation
+
+For some APIs the use of a service account also requires to impersonate a
+user. To support that the `ServiceAccountCredentials` constructors have an
+optional argument `impersonatedUser` to specify the user to impersonate.
+
+One example of this are the Google Apps APIs. See [Perform Google Apps
+Domain-Wide Delegation of Authority]
+(https://developers.google.com/admin-sdk/directory/v1/guides/delegation)
+for information on the additional security configuration required to
+enable this for a service account.
+
+
+#### Autonomous Application / Compute Engine using metadata service
+
+If an application wants to act autonomously and access e.g. data from a Google
+Cloud Project, then a Service Account can be used. In case the application is
+running on a ComputeEngine VM it is possible to start a VM with a set of scopes
+the VM is allowed to use. See the
+[documentation](https://developers.google.com/compute/docs/authentication#using)
+for further information.
+
+Here is an example of using the metadata service for obtaining access
+credentials on a ComputeEngine VM.
+```dart
+import "package:http/http.dart" as http;
+import "package:googleapis_auth/auth_io.dart";
+
+var client = new http.Client();
+obtainAccessCredentialsViaMetadataServer(client)
+    .then((AccessCredentials credentials) {
+  // Access credentials are available in [credentials].
+  // ...
+  client.close();
+});
+```
+
+or an authenticated HTTP client via
+
+```dart
+import "package:googleapis_auth/auth_io.dart";
+
+clientViaMetadataServer().then((AuthClient client) {
+  // [client] is an authenticated HTTP client.
+  // ...
+  client.close();
+});
+```
+The authenticated HTTP client can now access APIs.
+
+
+#### Accessing Public Data with API Key
+
+It is possible to access some APIs by just using an API key without OAuth2.
+
+A API key can be obtained on the Google Developers Console by creating a Key
+at the "Public API access" section
+(under DevConsole -> Project -> APIs & auth -> Credentials).
+
+A key can be created for different application types: For browser applications
+it is necessary to specify a set of referer URls from which the application
+would like to access APIs. For server applications it is possible to specify
+a list of IP ranges from which the client application would like to access APIs.
+
+Note that the ApiKey is used for quota and billing purposes and should not be
+disclosed to third parties.
+
+Here is an example of getting an HTTP client which uses an API key for making
+HTTP requests.
+
+```dart
+import "package:googleapis_auth/auth_io.dart";
+
+var client = clientViaApiKey('<api-key-from-devconsole>');
+// [client] can now be used to make REST calls to Google APIs.
+// ...
+client.close();
+```
+
+### More information
+
+More information can be obtained from official Google Developers documentation:
+- [OAuth2 to Access Google APIs](https://developers.google.com/accounts/docs/OAuth2?hl=fr)
+- [OAuth2 Playground](https://developers.google.com/oauthplayground/)
diff --git a/googleapis_auth/lib/auth.dart b/googleapis_auth/lib/auth.dart
new file mode 100644
index 0000000..91a8d06
--- /dev/null
+++ b/googleapis_auth/lib/auth.dart
@@ -0,0 +1,297 @@
+// 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.
+
+library googleapis_auth.auth;
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:http/http.dart';
+
+import 'src/auth_http_utils.dart';
+import 'src/crypto/pem.dart';
+import 'src/crypto/rsa.dart';
+import 'src/http_client_base.dart';
+import 'src/utils.dart';
+
+/// An OAuth2 access token.
+class AccessToken {
+  /// The token type, usually "Bearer"
+  final String type;
+
+  /// The access token data.
+  final String data;
+
+  /// Time at which the token will be expired (UTC time)
+  final DateTime expiry;
+
+  /// [expiry] must be a UTC `DateTime`.
+  AccessToken(this.type, this.data, this.expiry) {
+    if (!expiry.isUtc) {
+      throw ArgumentError('The expiry date must be a Utc DateTime.');
+    }
+  }
+
+  bool get hasExpired => DateTime.now().toUtc().isAfter(expiry);
+
+  @override
+  String toString() => 'AccessToken(type=$type, data=$data, expiry=$expiry)';
+}
+
+/// OAuth2 Credentials.
+class AccessCredentials {
+  /// An access token.
+  final AccessToken accessToken;
+
+  /// A refresh token, which can be used to refresh the access credentials.
+  final String? refreshToken;
+
+  /// A JWT used in calls to Google APIs that accept an id_token param.
+  final String? idToken;
+
+  /// Scopes these credentials are valid for.
+  final List<String> scopes;
+
+  AccessCredentials(
+    this.accessToken,
+    this.refreshToken,
+    this.scopes, {
+    this.idToken,
+  });
+}
+
+/// Represents the client application's credentials.
+class ClientId {
+  /// The identifier used to identify this application to the server.
+  final String identifier;
+
+  /// The client secret used to identify this application to the server.
+  final String? secret;
+
+  ClientId(this.identifier, this.secret);
+
+  ClientId.serviceAccount(this.identifier) : secret = null;
+}
+
+/// Represents credentials for a service account.
+class ServiceAccountCredentials {
+  /// The email address of this service account.
+  final String email;
+
+  /// The clientId.
+  final ClientId clientId;
+
+  /// Private key.
+  final String privateKey;
+
+  /// Impersonated user, if any. If not impersonating any user this is `null`.
+  final String? impersonatedUser;
+
+  /// Private key as an [RSAPrivateKey].
+  final RSAPrivateKey privateRSAKey;
+
+  /// Creates a new [ServiceAccountCredentials] from JSON.
+  ///
+  /// [json] can be either a [Map] or a JSON map encoded as a [String].
+  ///
+  /// The optional named argument [impersonatedUser] is used to set the user
+  /// to impersonate if impersonating a user.
+  factory ServiceAccountCredentials.fromJson(json, {String? impersonatedUser}) {
+    if (json is String) {
+      json = jsonDecode(json);
+    }
+    if (json is! Map) {
+      throw ArgumentError('json must be a Map or a String encoding a Map.');
+    }
+    final identifier = json['client_id'];
+    final privateKey = json['private_key'];
+    final email = json['client_email'];
+    final type = json['type'];
+
+    if (type != 'service_account') {
+      throw ArgumentError('The given credentials are not of type '
+          'service_account (was: $type).');
+    }
+
+    if (identifier == null || privateKey == null || email == null) {
+      throw ArgumentError('The given credentials do not contain all the '
+          'fields: client_id, private_key and client_email.');
+    }
+
+    final clientId = ClientId(identifier, null);
+    return ServiceAccountCredentials(email, clientId, privateKey,
+        impersonatedUser: impersonatedUser);
+  }
+
+  /// Creates a new [ServiceAccountCredentials].
+  ///
+  /// [email] is the e-mail address of the service account.
+  ///
+  /// [clientId] is the client ID for the service account.
+  ///
+  /// [privateKey] is the base 64 encoded, unencrypted private key, including
+  /// the '-----BEGIN PRIVATE KEY-----' and '-----END PRIVATE KEY-----'
+  /// boundaries.
+  ///
+  /// The optional named argument [impersonatedUser] is used to set the user
+  /// to impersonate if impersonating a user is needed.
+  ServiceAccountCredentials(
+    this.email,
+    this.clientId,
+    this.privateKey, {
+    this.impersonatedUser,
+  }) : privateRSAKey = keyFromString(privateKey);
+}
+
+/// A authenticated HTTP client.
+abstract class AuthClient implements Client {
+  /// The credentials currently used for making HTTP requests.
+  AccessCredentials get credentials;
+}
+
+/// A autorefreshing, authenticated HTTP client.
+abstract class AutoRefreshingAuthClient implements AuthClient {
+  /// A broadcast stream of [AccessCredentials].
+  ///
+  /// A listener will get notified when new [AccessCredentials] were obtained.
+  Stream<AccessCredentials> get credentialUpdates;
+}
+
+/// Thrown if an attempt to refresh a token failed.
+class RefreshFailedException implements Exception {
+  final String message;
+
+  RefreshFailedException(this.message);
+
+  @override
+  String toString() => message;
+}
+
+/// Thrown if an attempt to make an authorized request failed.
+class AccessDeniedException implements Exception {
+  final String message;
+
+  AccessDeniedException(this.message);
+
+  @override
+  String toString() => message;
+}
+
+/// Thrown if user did not give his consent.
+class UserConsentException implements Exception {
+  final String message;
+
+  UserConsentException(this.message);
+
+  @override
+  String toString() => message;
+}
+
+/// Obtain an `http.Client` which automatically authenticates
+/// requests using [credentials].
+///
+/// Note that the returned `RequestHandler` will not auto-refresh the given
+/// [credentials].
+///
+/// The user is responsible for closing the returned HTTP [Client].
+/// Closing the returned [Client] will not close [baseClient].
+AuthClient authenticatedClient(
+    Client baseClient, AccessCredentials credentials) {
+  if (credentials.accessToken.type != 'Bearer') {
+    throw ArgumentError('Only Bearer access tokens are accepted.');
+  }
+  return AuthenticatedClient(baseClient, credentials);
+}
+
+/// Obtain an `http.Client` which automatically refreshes [credentials]
+/// before they expire. Uses [baseClient] as a base for making authenticated
+/// http requests and for refreshing [credentials].
+///
+/// The user is responsible for closing the returned HTTP [Client].
+/// Closing the returned [Client] will not close [baseClient].
+AutoRefreshingAuthClient autoRefreshingClient(
+    ClientId clientId, AccessCredentials credentials, Client baseClient) {
+  if (credentials.accessToken.type != 'Bearer') {
+    throw ArgumentError('Only Bearer access tokens are accepted.');
+  }
+  if (credentials.refreshToken == null) {
+    throw ArgumentError('Refresh token in AccessCredentials was `null`.');
+  }
+  return AutoRefreshingClient(baseClient, clientId, credentials);
+}
+
+/// Tries to obtain refreshed [AccessCredentials] based on [credentials] using
+/// [client].
+Future<AccessCredentials> refreshCredentials(
+    ClientId clientId, AccessCredentials credentials, Client client) async {
+  final formValues = [
+    'client_id=${Uri.encodeComponent(clientId.identifier)}',
+    'client_secret=${Uri.encodeComponent(clientId.secret!)}',
+    'refresh_token=${Uri.encodeComponent(credentials.refreshToken!)}',
+    'grant_type=refresh_token',
+  ];
+
+  final body =
+      Stream<List<int>>.fromIterable([(ascii.encode(formValues.join('&')))]);
+  final request = RequestImpl('POST', _googleTokenUri, body);
+  request.headers['content-type'] = 'application/x-www-form-urlencoded';
+
+  final response = await client.send(request);
+  var contentType = response.headers['content-type'];
+  contentType = contentType?.toLowerCase();
+
+  if (contentType == null ||
+      (!contentType.contains('json') && !contentType.contains('javascript'))) {
+    await response.stream.drain().catchError((_) {});
+    throw Exception('Server responded with invalid content type: $contentType. '
+        'Expected json response.');
+  }
+
+  final jsonMap = await response.stream
+      .transform(ascii.decoder)
+      .transform(json.decoder)
+      .first as Map;
+
+  final idToken = jsonMap['id_token'];
+  final token = jsonMap['access_token'];
+  final seconds = jsonMap['expires_in'];
+  final tokenType = jsonMap['token_type'];
+  final error = jsonMap['error'];
+
+  if (response.statusCode != 200 && error != null) {
+    throw RefreshFailedException('Refreshing attempt failed. '
+        'Response was ${response.statusCode}. Error message was $error.');
+  }
+
+  if (token == null || seconds is! int || tokenType != 'Bearer') {
+    throw Exception('Refresing attempt failed. '
+        'Invalid server response.');
+  }
+
+  return AccessCredentials(AccessToken(tokenType, token, expiryDate(seconds)),
+      credentials.refreshToken, credentials.scopes,
+      idToken: idToken);
+}
+
+/// Available response types that can be requested when using the implicit
+/// browser login flow.
+///
+/// More information about these values can be found here:
+/// https://developers.google.com/identity/protocols/OpenIDConnect#response-type
+enum ResponseType {
+  /// Requests an access code.  This triggers the basic rather than the implicit
+  /// flow.
+  code,
+
+  /// Requests the user's identity token when running the implicit flow.
+  idToken,
+
+  /// Requests the user's current permissions.
+  permission,
+
+  /// Requests the user's access token when running the implicit flow.
+  token,
+}
+
+final _googleTokenUri = Uri.parse('https://accounts.google.com/o/oauth2/token');
diff --git a/googleapis_auth/lib/auth_browser.dart b/googleapis_auth/lib/auth_browser.dart
new file mode 100644
index 0000000..3d3bb54
--- /dev/null
+++ b/googleapis_auth/lib/auth_browser.dart
@@ -0,0 +1,280 @@
+// 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.
+
+library googleapis_auth.auth_browser;
+
+import 'dart:async';
+
+import 'package:http/browser_client.dart';
+import 'package:http/http.dart';
+
+import 'auth.dart';
+import 'src/auth_http_utils.dart';
+import 'src/http_client_base.dart';
+import 'src/oauth2_flows/implicit.dart';
+
+export 'auth.dart';
+
+/// Obtains a HTTP client which uses the given [apiKey] for making HTTP
+/// requests.
+///
+/// Note that the returned client should *only* be used for making HTTP requests
+/// to Google Services. The [apiKey] should not be disclosed to third parties.
+///
+/// The user is responsible for closing the returned HTTP [Client].
+/// Closing the returned [Client] will not close [baseClient].
+Client clientViaApiKey(String apiKey, {Client? baseClient}) {
+  if (baseClient == null) {
+    baseClient = BrowserClient();
+  } else {
+    baseClient = nonClosingClient(baseClient);
+  }
+  return ApiKeyClient(baseClient, apiKey);
+}
+
+/// Will create and complete with a [BrowserOAuth2Flow] object.
+///
+/// This function will perform an implicit browser based oauth2 flow.
+///
+/// It will load Google's `gapi` library and initialize it. After initialization
+/// it will complete with a [BrowserOAuth2Flow] object. The flow object can be
+/// used to obtain `AccessCredentials` or an authenticated HTTP client.
+///
+/// If loading or initializing the `gapi` library results in an error, this
+/// future will complete with an error.
+///
+/// If [baseClient] is not given, one will be automatically created. It will be
+/// used for making authenticated HTTP requests. See [BrowserOAuth2Flow].
+///
+/// The [ClientId] can be obtained in the Google Cloud Console.
+///
+/// The user is responsible for closing the returned [BrowserOAuth2Flow] object.
+/// Closing the returned [BrowserOAuth2Flow] will not close [baseClient]
+/// if one was given.
+Future<BrowserOAuth2Flow> createImplicitBrowserFlow(
+    ClientId clientId, List<String> scopes,
+    {Client? baseClient}) {
+  final refCountedClient = baseClient == null
+      ? RefCountedClient(BrowserClient())
+      : RefCountedClient(baseClient, initialRefCount: 2);
+
+  final flow = ImplicitFlow(clientId.identifier, scopes);
+  return flow.initialize().catchError((error, stack) {
+    refCountedClient.close();
+    return Future.error(error, stack);
+  }).then((_) => BrowserOAuth2Flow._(flow, refCountedClient));
+}
+
+/// Used for obtaining oauth2 access credentials.
+///
+/// Warning:
+///
+/// The methods `obtainAccessCredentialsViaUserConsent` and
+/// `clientViaUserConsent` try to open a popup window for the user authorization
+/// dialog.
+///
+/// In order to prevent browsers from blocking the popup window, these
+/// methods should only be called inside an event handler, since most
+/// browsers do not block popup windows created in response to a user
+/// interaction.
+class BrowserOAuth2Flow {
+  final ImplicitFlow _flow;
+  final RefCountedClient _client;
+
+  bool _wasClosed = false;
+
+  /// The HTTP client passed in will be closed if `close` was called and all
+  /// generated HTTP clients via [clientViaUserConsent] were closed.
+  BrowserOAuth2Flow._(this._flow, this._client);
+
+  /// Obtain oauth2 [AccessCredentials].
+  ///
+  /// If [immediate] is `true` there will be no user involvement. If the user
+  /// is either not logged in or has not already granted the application access,
+  /// a `UserConsentException` will be thrown.
+  ///
+  /// If [immediate] is `false` the user might be asked to login (if he is not
+  /// already logged in) and might get asked to grant the application access
+  /// (if the application hasn't been granted access before).
+  ///
+  /// If [force] is `true` this will create a popup window and ask the user to
+  /// grant the application offline access. In case the user is not already
+  /// logged in, he will be presented with an login dialog first.
+  ///
+  /// If [force] is `false` this will only create a popup window if the user
+  /// has not already granted the application access.
+  ///
+  /// If [loginHint] is not `null`, it will be passed to the server as a hint
+  /// to which user is being signed-in.  This can e.g. be an email or a User ID
+  /// which might be used as pre-selection in the sign-in flow.
+  ///
+  /// If [responseTypes] is not `null` or empty, it will be sent to the server
+  /// to inform the server of the type of responses to reply with.
+  ///
+  /// The returned future will complete with `AccessCredentials` if the user
+  /// has given the application access to it's data. Otherwise the future will
+  /// complete with a `UserConsentException`.
+  ///
+  /// In case another error occurs the returned future will complete with an
+  /// `Exception`.
+  Future<AccessCredentials> obtainAccessCredentialsViaUserConsent(
+      {bool immediate = false,
+      bool force = false,
+      String? loginHint,
+      List<ResponseType>? responseTypes}) {
+    _ensureOpen();
+    return _flow.login(
+        force: force,
+        immediate: immediate,
+        loginHint: loginHint,
+        responseTypes: responseTypes);
+  }
+
+  /// Obtains [AccessCredentials] and returns an authenticated HTTP client.
+  ///
+  /// After obtaining access credentials, this function will return an HTTP
+  /// [Client]. HTTP requests made on the returned client will get an
+  /// additional `Authorization` header with the `AccessCredentials` obtained.
+  ///
+  /// In case the `AccessCredentials` expire, it will try to obtain new ones
+  /// without user consent.
+  ///
+  /// See [obtainAccessCredentialsViaUserConsent] for how credentials will be
+  /// obtained. Errors from [obtainAccessCredentialsViaUserConsent] will be let
+  /// through to the returned `Future` of this function and to the returned
+  /// HTTP client (in case of credential refreshes).
+  ///
+  /// The returned HTTP client will forward errors from lower levels via it's
+  /// `Future<Response>` or it's `Response.read()` stream.
+  ///
+  /// The user is responsible for closing the returned HTTP client.
+  Future<AutoRefreshingAuthClient> clientViaUserConsent(
+          {bool immediate = false, String? loginHint}) =>
+      obtainAccessCredentialsViaUserConsent(
+              immediate: immediate, loginHint: loginHint)
+          .then(_clientFromCredentials);
+
+  /// Obtains [AccessCredentials] and an authorization code which can be
+  /// exchanged for permanent access credentials.
+  ///
+  /// Use case:
+  /// A web application might want to get consent for accessing data on behalf
+  /// of a user. The client part is a dynamic webapp which wants to open a
+  /// popup which asks the user for consent. The webapp might want to use the
+  /// credentials to make API calls, but the server may want to have offline
+  /// access to user data as well.
+  ///
+  /// If [force] is `true` this will create a popup window and ask the user to
+  /// grant the application offline access. In case the user is not already
+  /// logged in, he will be presented with an login dialog first.
+  ///
+  /// If [force] is `false` this will only create a popup window if the user
+  /// has not already granted the application access. Please note that the
+  /// authorization code can only be exchanged for a refresh token if the user
+  /// had to grant access via the popup window. Otherwise the code exchange
+  /// will only give an access token.
+  ///
+  /// If [loginHint] is not `null`, it will be passed to the server as a hint
+  /// to which user is being signed-in.  This can e.g. be an email or a User ID
+  /// which might be used as pre-selection in the sign-in flow.
+  ///
+  /// If [immediate] is `true` there will be no user involvement. If the user
+  /// is either not logged in or has not already granted the application access,
+  /// a `UserConsentException` will be thrown.
+  Future<HybridFlowResult> runHybridFlow(
+      {bool force = true, bool immediate = false, String? loginHint}) async {
+    _ensureOpen();
+    final result = await _flow.loginHybrid(
+        force: force, immediate: immediate, loginHint: loginHint);
+    return HybridFlowResult(this, result.credential, result.code);
+  }
+
+  /// Will close this [BrowserOAuth2Flow] object and the HTTP [Client] it is
+  /// using.
+  ///
+  /// The clients obtained via [clientViaUserConsent] will continue to work.
+  /// The client obtained via `newClient` of obtained [HybridFlowResult] objects
+  /// will continue to work.
+  ///
+  /// After this flow object and all obtained clients were closed the underlying
+  /// HTTP client will be closed as well.
+  ///
+  /// After calling this `close` method, calls to [clientViaUserConsent],
+  /// [obtainAccessCredentialsViaUserConsent] and to `newClient` on returned
+  /// [HybridFlowResult] objects will fail.
+  void close() {
+    _ensureOpen();
+    _wasClosed = true;
+    _client.close();
+  }
+
+  void _ensureOpen() {
+    if (_wasClosed) {
+      throw StateError('BrowserOAuth2Flow has already been closed.');
+    }
+  }
+
+  AutoRefreshingAuthClient _clientFromCredentials(AccessCredentials cred) {
+    _ensureOpen();
+    _client.acquire();
+    return _AutoRefreshingBrowserClient(_client, cred, _flow);
+  }
+}
+
+/// Represents the result of running a browser based hybrid flow.
+///
+/// The `credentials` field holds credentials which can be used on the client
+/// side. The `newClient` function can be used to make a new authenticated HTTP
+/// client using these credentials.
+///
+/// The `authorizationCode` can be sent to the server, which knows the
+/// "client secret" and can exchange it with long-lived access credentials.
+///
+/// See the `obtainAccessCredentialsViaCodeExchange` function in the
+/// `googleapis_auth.auth_io` library for more details on how to use the
+/// authorization code.
+class HybridFlowResult {
+  final BrowserOAuth2Flow _flow;
+
+  /// Access credentials for making authenticated HTTP requests.
+  final AccessCredentials credentials;
+
+  /// The authorization code received from the authorization endpoint.
+  ///
+  /// The auth code can be used to receive permanent access credentials.
+  /// This requires a confidential client which can keep a secret.
+  final String? authorizationCode;
+
+  HybridFlowResult(this._flow, this.credentials, this.authorizationCode);
+
+  AutoRefreshingAuthClient newClient() {
+    _flow._ensureOpen();
+    return _flow._clientFromCredentials(credentials);
+  }
+}
+
+class _AutoRefreshingBrowserClient extends AutoRefreshDelegatingClient {
+  @override
+  AccessCredentials credentials;
+  final ImplicitFlow _flow;
+  Client _authClient;
+
+  _AutoRefreshingBrowserClient(Client client, this.credentials, this._flow)
+      : _authClient = authenticatedClient(client, credentials),
+        super(client);
+
+  @override
+  Future<StreamedResponse> send(BaseRequest request) {
+    if (!credentials.accessToken.hasExpired) {
+      return _authClient.send(request);
+    } else {
+      return _flow.login(immediate: true).then((newCredentials) {
+        credentials = newCredentials;
+        notifyAboutNewCredentials(credentials);
+        _authClient = authenticatedClient(baseClient, credentials);
+        return _authClient.send(request);
+      });
+    }
+  }
+}
diff --git a/googleapis_auth/lib/auth_io.dart b/googleapis_auth/lib/auth_io.dart
new file mode 100644
index 0000000..86dc053
--- /dev/null
+++ b/googleapis_auth/lib/auth_io.dart
@@ -0,0 +1,411 @@
+// 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.
+
+library googleapis_auth.auth_io;
+
+import 'dart:io';
+
+import 'package:http/http.dart';
+
+import 'auth.dart';
+import 'src/adc_utils.dart';
+import 'src/auth_http_utils.dart';
+import 'src/http_client_base.dart';
+import 'src/oauth2_flows/auth_code.dart';
+import 'src/oauth2_flows/jwt.dart';
+import 'src/oauth2_flows/metadata_server.dart';
+import 'src/typedefs.dart';
+
+export 'auth.dart';
+export 'src/typedefs.dart';
+
+/// Create a client using
+/// [Application Default Credentials](https://cloud.google.com/docs/authentication/production).
+///
+/// Looks for credentials in the following order of preference:
+///  1. A JSON file whose path is specified by `GOOGLE_APPLICATION_CREDENTIALS`,
+///     this file typically contains [exported service account keys][svc-keys].
+///  2. A JSON file created by
+///     [`gcloud auth application-default login`][gcloud-login]
+///     in a well-known location (`%APPDATA%/gcloud/application_default_credentials.json`
+///     on Windows and `$HOME/.config/gcloud/application_default_credentials.json` on Linux/Mac).
+///  3. On Google Compute Engine and App Engine Flex we fetch credentials from
+///     [GCE metadata service][metadata].
+///
+/// [metadata]: https://cloud.google.com/compute/docs/storing-retrieving-metadata
+/// [svc-keys]: https://cloud.google.com/docs/authentication/getting-started
+/// [gcloud-login]: https://cloud.google.com/sdk/gcloud/reference/auth/application-default/login
+Future<AutoRefreshingAuthClient> clientViaApplicationDefaultCredentials({
+  required List<String> scopes,
+  Client? baseClient,
+}) async {
+  if (baseClient == null) {
+    baseClient = Client();
+  } else {
+    baseClient = nonClosingClient(baseClient);
+  }
+
+  // If env var specifies a file to load credentials from we'll do that.
+  final credsEnv = Platform.environment['GOOGLE_APPLICATION_CREDENTIALS'];
+  if (credsEnv != null && credsEnv.isNotEmpty) {
+    // If env var is specific and not empty, we always try to load, even if
+    // the file doesn't exist.
+    return await fromApplicationsCredentialsFile(
+      File(credsEnv),
+      'GOOGLE_APPLICATION_CREDENTIALS',
+      scopes,
+      baseClient,
+    );
+  }
+
+  // Attempt to use file created by `gcloud auth application-default login`
+  File credFile;
+  if (Platform.isWindows) {
+    credFile = File.fromUri(Uri.directory(Platform.environment['APPDATA']!)
+        .resolve('gcloud/application_default_credentials.json'));
+  } else {
+    credFile = File.fromUri(Uri.directory(Platform.environment['HOME']!)
+        .resolve('.config/gcloud/application_default_credentials.json'));
+  }
+  // Only try to load from credFile if it exists.
+  if (await credFile.exists()) {
+    return await fromApplicationsCredentialsFile(
+      credFile,
+      '`gcloud auth application-default login`',
+      scopes,
+      baseClient,
+    );
+  }
+
+  return await clientViaMetadataServer(baseClient: baseClient);
+}
+
+/// Obtains oauth2 credentials and returns an authenticated HTTP client.
+///
+/// See [obtainAccessCredentialsViaUserConsent] for specifics about the
+/// arguments used for obtaining access credentials.
+///
+/// Once access credentials have been obtained, this function will complete
+/// with an auto-refreshing HTTP client. Once the `AccessCredentials` expire
+/// it will use it's refresh token (if available) to obtain new credentials.
+/// See [autoRefreshingClient] for more information.
+///
+/// If [baseClient] is not given, one will be automatically created. It will be
+/// used for making authenticated HTTP requests.
+///
+/// The user is responsible for closing the returned HTTP [Client].
+/// Closing the returned [Client] will not close [baseClient].
+Future<AutoRefreshingAuthClient> clientViaUserConsent(
+    ClientId clientId, List<String> scopes, PromptUserForConsent userPrompt,
+    {Client? baseClient}) async {
+  var closeUnderlyingClient = false;
+  if (baseClient == null) {
+    baseClient = Client();
+    closeUnderlyingClient = true;
+  }
+
+  final flow = AuthorizationCodeGrantServerFlow(
+      clientId, scopes, baseClient, userPrompt);
+
+  AccessCredentials credentials;
+
+  try {
+    credentials = await flow.run();
+  } catch (e) {
+    if (closeUnderlyingClient) {
+      baseClient.close();
+    }
+    rethrow;
+  }
+  return AutoRefreshingClient(baseClient, clientId, credentials,
+      closeUnderlyingClient: closeUnderlyingClient);
+}
+
+/// Obtains oauth2 credentials and returns an authenticated HTTP client.
+///
+/// See [obtainAccessCredentialsViaUserConsentManual] for specifics about the
+/// arguments used for obtaining access credentials.
+///
+/// Once access credentials have been obtained, this function will complete
+/// with an auto-refreshing HTTP client. Once the `AccessCredentials` expire
+/// it will use it's refresh token (if available) to obtain new credentials.
+/// See [autoRefreshingClient] for more information.
+///
+/// If [baseClient] is not given, one will be automatically created. It will be
+/// used for making authenticated HTTP requests.
+///
+/// The user is responsible for closing the returned HTTP [Client].
+/// Closing the returned [Client] will not close [baseClient].
+Future<AutoRefreshingAuthClient> clientViaUserConsentManual(ClientId clientId,
+    List<String> scopes, PromptUserForConsentManual userPrompt,
+    {Client? baseClient}) async {
+  var closeUnderlyingClient = false;
+  if (baseClient == null) {
+    baseClient = Client();
+    closeUnderlyingClient = true;
+  }
+
+  final flow = AuthorizationCodeGrantManualFlow(
+      clientId, scopes, baseClient, userPrompt);
+
+  AccessCredentials credentials;
+
+  try {
+    credentials = await flow.run();
+  } catch (e) {
+    if (closeUnderlyingClient) {
+      baseClient.close();
+    }
+    rethrow;
+  }
+
+  return AutoRefreshingClient(baseClient, clientId, credentials,
+      closeUnderlyingClient: closeUnderlyingClient);
+}
+
+/// Obtains oauth2 credentials and returns an authenticated HTTP client.
+///
+/// See [obtainAccessCredentialsViaServiceAccount] for specifics about the
+/// arguments used for obtaining access credentials.
+///
+/// Once access credentials have been obtained, this function will complete
+/// with an auto-refreshing HTTP client. Once the `AccessCredentials` expire
+/// it will obtain new access credentials.
+///
+/// If [baseClient] is not given, one will be automatically created. It will be
+/// used for making authenticated HTTP requests and for obtaining access
+/// credentials.
+///
+/// The user is responsible for closing the returned HTTP [Client].
+/// Closing the returned [Client] will not close [baseClient].
+Future<AutoRefreshingAuthClient> clientViaServiceAccount(
+    ServiceAccountCredentials clientCredentials, List<String> scopes,
+    {Client? baseClient}) async {
+  if (baseClient == null) {
+    baseClient = Client();
+  } else {
+    baseClient = nonClosingClient(baseClient);
+  }
+
+  final flow = JwtFlow(clientCredentials.email, clientCredentials.privateRSAKey,
+      clientCredentials.impersonatedUser, scopes, baseClient);
+
+  AccessCredentials credentials;
+  try {
+    credentials = await flow.run();
+  } catch (e) {
+    baseClient.close();
+    rethrow;
+  }
+
+  return _ServiceAccountClient(baseClient, credentials, flow);
+}
+
+/// Obtains oauth2 credentials and returns an authenticated HTTP client.
+///
+/// See [obtainAccessCredentialsViaMetadataServer] for specifics about the
+/// arguments used for obtaining access credentials.
+///
+/// Once access credentials have been obtained, this function will complete
+/// with an auto-refreshing HTTP client. Once the `AccessCredentials` expire
+/// it will obtain new access credentials.
+///
+/// If [baseClient] is not given, one will be automatically created. It will be
+/// used for making authenticated HTTP requests and for obtaining access
+/// credentials.
+///
+/// The user is responsible for closing the returned HTTP [Client].
+/// Closing the returned [Client] will not close [baseClient].
+Future<AutoRefreshingAuthClient> clientViaMetadataServer(
+    {Client? baseClient}) async {
+  if (baseClient == null) {
+    baseClient = Client();
+  } else {
+    baseClient = nonClosingClient(baseClient);
+  }
+
+  final flow = MetadataServerAuthorizationFlow(baseClient);
+
+  AccessCredentials credentials;
+
+  try {
+    credentials = await flow.run();
+  } catch (e) {
+    baseClient.close();
+    rethrow;
+  }
+  return _MetadataServerClient(baseClient, credentials, flow);
+}
+
+/// Obtains a HTTP client which uses the given [apiKey] for making HTTP
+/// requests.
+///
+/// Note that the returned client should *only* be used for making HTTP requests
+/// to Google Services. The [apiKey] should not be disclosed to third parties.
+///
+/// The user is responsible for closing the returned HTTP [Client].
+/// Closing the returned [Client] will not close [baseClient].
+Client clientViaApiKey(String apiKey, {Client? baseClient}) {
+  if (baseClient == null) {
+    baseClient = Client();
+  } else {
+    baseClient = nonClosingClient(baseClient);
+  }
+  return ApiKeyClient(baseClient, apiKey);
+}
+
+/// Obtain oauth2 [AccessCredentials] using the oauth2 authentication code flow.
+///
+/// The returned future will complete with `AccessCredentials` if the user
+/// has given the application access to it's data. Otherwise the future will
+/// complete with a `UserConsentException`.
+///
+/// In case another error occurs the returned future will complete with an
+/// `Exception`.
+///
+/// [userPrompt] will be used for directing the user/user-agent to a URI. See
+/// [PromptUserForConsent] for more information.
+///
+/// [client] will be used for obtaining `AccessCredentials` from an
+/// authorization code.
+///
+/// The [ClientId] can be obtained in the Google Cloud Console.
+Future<AccessCredentials> obtainAccessCredentialsViaUserConsent(
+        ClientId clientId,
+        List<String> scopes,
+        Client client,
+        PromptUserForConsent userPrompt) =>
+    AuthorizationCodeGrantServerFlow(clientId, scopes, client, userPrompt)
+        .run();
+
+/// Obtain oauth2 [AccessCredentials] using the oauth2 authentication code flow.
+///
+/// The returned future will complete with `AccessCredentials` if the user
+/// has given the application access to it's data. Otherwise the future will
+/// complete with a `UserConsentException`.
+///
+/// In case another error occurs the returned future will complete with an
+/// `Exception`.
+///
+/// [userPrompt] will be used for directing the user/user-agent to a URI. See
+/// [PromptUserForConsentManual] for more information.
+///
+/// [client] will be used for obtaining `AccessCredentials` from an
+/// authorization code.
+///
+/// The [ClientId] can be obtained in the Google Cloud Console.
+Future<AccessCredentials> obtainAccessCredentialsViaUserConsentManual(
+        ClientId clientId,
+        List<String> scopes,
+        Client client,
+        PromptUserForConsentManual userPrompt) =>
+    AuthorizationCodeGrantManualFlow(clientId, scopes, client, userPrompt)
+        .run();
+
+/// Obtain oauth2 [AccessCredentials] using service account credentials.
+///
+/// In case the service account has no access to the requested scopes or another
+/// error occurs the returned future will complete with an `Exception`.
+///
+/// [baseClient] will be used for obtaining `AccessCredentials`.
+///
+/// The [ServiceAccountCredentials] can be obtained in the Google Cloud Console.
+Future<AccessCredentials> obtainAccessCredentialsViaServiceAccount(
+        ServiceAccountCredentials clientCredentials,
+        List<String> scopes,
+        Client baseClient) =>
+    JwtFlow(clientCredentials.email, clientCredentials.privateRSAKey,
+            clientCredentials.impersonatedUser, scopes, baseClient)
+        .run();
+
+/// Obtain oauth2 [AccessCredentials] using the metadata API on ComputeEngine.
+///
+/// In case the VM was not configured with access to the requested scopes or an
+/// error occurs the returned future will complete with an `Exception`.
+///
+/// [baseClient] will be used for obtaining `AccessCredentials`.
+///
+/// No credentials are needed. But this function is only intended to work on a
+/// Google Compute Engine VM with configured access to Google APIs.
+Future<AccessCredentials> obtainAccessCredentialsViaMetadataServer(
+        Client baseClient) =>
+    MetadataServerAuthorizationFlow(baseClient).run();
+
+/// Obtain oauth2 [AccessCredentials] by exchanging an authorization code.
+///
+/// Running a hybrid oauth2 flow as described in the
+/// `googleapis_auth.auth_browser` library results in a `HybridFlowResult` which
+/// contains short-lived [AccessCredentials] for the client and an authorization
+/// code. This authorization code needs to be transferred to the server, which
+/// can exchange it against long-lived [AccessCredentials].
+///
+/// If the authorization code was obtained using the mentioned hybrid flow, the
+/// [redirectUrl] must be `"postmessage"` (default).
+///
+/// If you obtained the authorization code using a different mechanism, the
+/// [redirectUrl] must be the same that was used to obtain the code.
+///
+/// NOTE: Only the server application will know the `client secret` - which is
+/// necessary to exchange an authorization code against access tokens.
+///
+/// NOTE: It is important to transmit the authorization code in a secure manner
+/// to the server. You should use "anti-request forgery state tokens" to guard
+/// against "cross site request forgery" attacks.
+/// An example on how to do this can be found here:
+///   https://developers.google.com/+/web/signin/server-side-flow
+Future<AccessCredentials> obtainAccessCredentialsViaCodeExchange(
+        Client baseClient, ClientId clientId, String code,
+        {String redirectUrl = 'postmessage'}) =>
+    obtainAccessCredentialsUsingCode(clientId, code, redirectUrl, baseClient);
+
+/// Will close the underlying `http.Client`.
+class _ServiceAccountClient extends AutoRefreshDelegatingClient {
+  final JwtFlow flow;
+  @override
+  AccessCredentials credentials;
+  late Client authClient;
+
+  _ServiceAccountClient(Client client, this.credentials, this.flow)
+      : super(client) {
+    authClient = authenticatedClient(baseClient, credentials);
+  }
+
+  @override
+  Future<StreamedResponse> send(BaseRequest request) async {
+    if (!credentials.accessToken.hasExpired) {
+      return authClient.send(request);
+    } else {
+      final newCredentials = await flow.run();
+      notifyAboutNewCredentials(newCredentials);
+      credentials = newCredentials;
+      authClient = authenticatedClient(baseClient, credentials);
+      return authClient.send(request);
+    }
+  }
+}
+
+/// Will close the underlying `http.Client`.
+class _MetadataServerClient extends AutoRefreshDelegatingClient {
+  final MetadataServerAuthorizationFlow flow;
+  @override
+  AccessCredentials credentials;
+  Client authClient;
+
+  _MetadataServerClient(Client client, this.credentials, this.flow)
+      : authClient = authenticatedClient(client, credentials),
+        super(client);
+
+  @override
+  Future<StreamedResponse> send(BaseRequest request) async {
+    if (!credentials.accessToken.hasExpired) {
+      return authClient.send(request);
+    }
+
+    final newCredentials = await flow.run();
+    notifyAboutNewCredentials(newCredentials);
+    credentials = newCredentials;
+    authClient = authenticatedClient(baseClient, credentials);
+    return authClient.send(request);
+  }
+}
diff --git a/googleapis_auth/lib/src/adc_utils.dart b/googleapis_auth/lib/src/adc_utils.dart
new file mode 100644
index 0000000..d88240f
--- /dev/null
+++ b/googleapis_auth/lib/src/adc_utils.dart
@@ -0,0 +1,55 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:http/http.dart';
+
+import '../auth_io.dart';
+import 'auth_http_utils.dart';
+
+Future<AutoRefreshingAuthClient> fromApplicationsCredentialsFile(
+  File file,
+  String fileSource,
+  List<String> scopes,
+  Client baseClient,
+) async {
+  Object? credentials;
+  try {
+    credentials = json.decode(await file.readAsString());
+  } on IOException {
+    throw Exception(
+      'Failed to read credentials file from $fileSource',
+    );
+  } on FormatException {
+    throw Exception(
+      'Failed to parse JSON from credentials file from $fileSource',
+    );
+  }
+
+  if (credentials is Map && credentials['type'] == 'authorized_user') {
+    final clientId = ClientId(
+      credentials['client_id'],
+      credentials['client_secret'],
+    );
+    return AutoRefreshingClient(
+      baseClient,
+      clientId,
+      await refreshCredentials(
+        clientId,
+        AccessCredentials(
+          // Hack: Create empty credentials that have expired.
+          AccessToken('Bearer', '', DateTime(0).toUtc()),
+          credentials['refresh_token'],
+          scopes,
+        ),
+        baseClient,
+      ),
+      quotaProject: credentials['quota_project_id'],
+    );
+  }
+  return await clientViaServiceAccount(
+    ServiceAccountCredentials.fromJson(credentials),
+    scopes,
+    baseClient: baseClient,
+  );
+}
diff --git a/googleapis_auth/lib/src/auth_http_utils.dart b/googleapis_auth/lib/src/auth_http_utils.dart
new file mode 100644
index 0000000..d327160
--- /dev/null
+++ b/googleapis_auth/lib/src/auth_http_utils.dart
@@ -0,0 +1,145 @@
+// 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.
+
+library googleapis_auth;
+
+import 'dart:async';
+
+import 'package:http/http.dart';
+
+import '../auth.dart';
+import 'http_client_base.dart';
+
+/// Will close the underlying `http.Client` depending on a constructor argument.
+class AuthenticatedClient extends DelegatingClient implements AuthClient {
+  @override
+  final AccessCredentials credentials;
+  final String? quotaProject;
+
+  AuthenticatedClient(Client client, this.credentials, {this.quotaProject})
+      : super(client, closeUnderlyingClient: false);
+
+  @override
+  Future<StreamedResponse> send(BaseRequest request) async {
+    // Make new request object and perform the authenticated request.
+    final modifiedRequest =
+        RequestImpl(request.method, request.url, request.finalize());
+    modifiedRequest.headers.addAll(request.headers);
+    modifiedRequest.headers['Authorization'] =
+        'Bearer ${credentials.accessToken.data}';
+    if (quotaProject != null) {
+      modifiedRequest.headers['X-Goog-User-Project'] = quotaProject!;
+    }
+    final response = await baseClient.send(modifiedRequest);
+    final wwwAuthenticate = response.headers['www-authenticate'];
+    if (wwwAuthenticate != null) {
+      await response.stream.drain();
+      throw AccessDeniedException('Access was denied '
+          '(www-authenticate header was: $wwwAuthenticate).');
+    }
+    return response;
+  }
+}
+
+/// Adds 'key' query parameter when making HTTP requests.
+///
+/// If 'key' is already present on the URI, it will complete with an exception.
+/// This will prevent accidental overrides of a query parameter with the API
+/// key.
+class ApiKeyClient extends DelegatingClient {
+  final String _encodedApiKey;
+
+  ApiKeyClient(Client client, String apiKey)
+      : _encodedApiKey = Uri.encodeQueryComponent(apiKey),
+        super(client, closeUnderlyingClient: true);
+
+  @override
+  Future<StreamedResponse> send(BaseRequest request) {
+    var url = request.url;
+    if (url.queryParameters.containsKey('key')) {
+      return Future.error(Exception(
+          'Tried to make a HTTP request which has already a "key" query '
+          'parameter. Adding the API key would override that existing value.'));
+    }
+
+    if (url.query == '') {
+      url = url.replace(query: 'key=$_encodedApiKey');
+    } else {
+      url = url.replace(query: '${url.query}&key=$_encodedApiKey');
+    }
+
+    final modifiedRequest =
+        RequestImpl(request.method, url, request.finalize());
+    modifiedRequest.headers.addAll(request.headers);
+    return baseClient.send(modifiedRequest);
+  }
+}
+
+/// Will close the underlying `http.Client` depending on a constructor argument.
+class AutoRefreshingClient extends AutoRefreshDelegatingClient {
+  final ClientId clientId;
+  final String? quotaProject;
+  @override
+  AccessCredentials credentials;
+  late Client authClient;
+
+  AutoRefreshingClient(
+    Client client,
+    this.clientId,
+    this.credentials, {
+    bool closeUnderlyingClient = false,
+    this.quotaProject,
+  })  : assert(credentials.accessToken.type == 'Bearer'),
+        assert(credentials.refreshToken != null),
+        super(client, closeUnderlyingClient: closeUnderlyingClient) {
+    authClient = AuthenticatedClient(
+      baseClient,
+      credentials,
+      quotaProject: quotaProject,
+    );
+  }
+
+  @override
+  Future<StreamedResponse> send(BaseRequest request) async {
+    if (!credentials.accessToken.hasExpired) {
+      // TODO: Can this return a "access token expired" message?
+      // If so, we should handle it.
+      return authClient.send(request);
+    } else {
+      final cred = await refreshCredentials(clientId, credentials, baseClient);
+      notifyAboutNewCredentials(cred);
+      credentials = cred;
+      authClient = AuthenticatedClient(
+        baseClient,
+        cred,
+        quotaProject: quotaProject,
+      );
+      return authClient.send(request);
+    }
+  }
+}
+
+abstract class AutoRefreshDelegatingClient extends DelegatingClient
+    implements AutoRefreshingAuthClient {
+  final StreamController<AccessCredentials> _credentialStreamController =
+      StreamController.broadcast(sync: true);
+
+  AutoRefreshDelegatingClient(Client client,
+      {bool closeUnderlyingClient = true})
+      : super(client, closeUnderlyingClient: closeUnderlyingClient);
+
+  @override
+  Stream<AccessCredentials> get credentialUpdates =>
+      _credentialStreamController.stream;
+
+  void notifyAboutNewCredentials(AccessCredentials credentials) {
+    _credentialStreamController.add(credentials);
+  }
+
+  @override
+  void close() {
+    _credentialStreamController.close();
+    super.close();
+  }
+}
diff --git a/googleapis_auth/lib/src/crypto/asn1.dart b/googleapis_auth/lib/src/crypto/asn1.dart
new file mode 100644
index 0000000..76eb4a8
--- /dev/null
+++ b/googleapis_auth/lib/src/crypto/asn1.dart
@@ -0,0 +1,141 @@
+// 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.
+
+library appengine_auth.asn;
+
+import 'dart:typed_data';
+
+import 'rsa.dart';
+
+class ASN1Parser {
+  static const integerTag = 0x02;
+  static const octetStringTag = 0x04;
+  static const nullTag = 0x05;
+  static const objectIdTag = 0x06;
+  static const sequenceTag = 0x30;
+
+  static ASN1Object parse(Uint8List bytes) {
+    Never invalidFormat(String msg) {
+      throw ArgumentError('Invalid DER encoding: $msg');
+    }
+
+    final data = ByteData.view(bytes.buffer);
+    var offset = 0;
+    final end = bytes.length;
+
+    void checkNBytesAvailable(int n) {
+      if ((offset + n) > end) {
+        invalidFormat('Tried to read more bytes than available.');
+      }
+    }
+
+    List<int> readBytes(int n) {
+      checkNBytesAvailable(n);
+
+      final integerBytes = bytes.sublist(offset, offset + n);
+      offset += n;
+      return integerBytes;
+    }
+
+    int readEncodedLength() {
+      checkNBytesAvailable(1);
+
+      final lengthByte = data.getUint8(offset++);
+
+      // Short length encoding form: This byte is the length itself.
+      if (lengthByte < 0x80) {
+        return lengthByte;
+      }
+
+      // Long length encoding form:
+      // This byte has in bits 0..6 the number of bytes following which encode
+      // the length.
+      var countLengthBytes = lengthByte & 0x7f;
+      checkNBytesAvailable(countLengthBytes);
+
+      var length = 0;
+      while (countLengthBytes > 0) {
+        length = (length << 8) | data.getUint8(offset++);
+        countLengthBytes--;
+      }
+      return length;
+    }
+
+    void readNullBytes() {
+      checkNBytesAvailable(1);
+      final nullByte = data.getUint8(offset++);
+      if (nullByte != 0x00) {
+        invalidFormat('Null byte expect, but was: $nullByte.');
+      }
+    }
+
+    ASN1Object decodeObject() {
+      checkNBytesAvailable(1);
+      final tag = bytes[offset++];
+      switch (tag) {
+        case integerTag:
+          final size = readEncodedLength();
+          return ASN1Integer(RSAAlgorithm.bytes2BigInt(readBytes(size)));
+        case octetStringTag:
+          final size = readEncodedLength();
+          return ASN1OctetString(readBytes(size));
+        case nullTag:
+          readNullBytes();
+          return ASN1Null();
+        case objectIdTag:
+          final size = readEncodedLength();
+          return ASN1ObjectIdentifier(readBytes(size));
+        case sequenceTag:
+          final lengthInBytes = readEncodedLength();
+          if ((offset + lengthInBytes) > end) {
+            invalidFormat('Tried to read more bytes than available.');
+          }
+          final endOfSequence = offset + lengthInBytes;
+
+          final objects = <ASN1Object>[];
+          while (offset < endOfSequence) {
+            objects.add(decodeObject());
+          }
+          return ASN1Sequence(objects);
+        default:
+          invalidFormat(
+              'Unexpected tag $tag at offset ${offset - 1} (end: $end).');
+      }
+    }
+
+    final obj = decodeObject();
+    if (offset != bytes.length) {
+      throw ArgumentError('More bytes than expected in ASN1 encoding.');
+    }
+    return obj;
+  }
+}
+
+abstract class ASN1Object {}
+
+class ASN1Sequence extends ASN1Object {
+  final List<ASN1Object> objects;
+
+  ASN1Sequence(this.objects);
+}
+
+class ASN1Integer extends ASN1Object {
+  final BigInt integer;
+
+  ASN1Integer(this.integer);
+}
+
+class ASN1OctetString extends ASN1Object {
+  final List<int> bytes;
+
+  ASN1OctetString(this.bytes);
+}
+
+class ASN1ObjectIdentifier extends ASN1Object {
+  final List<int> bytes;
+
+  ASN1ObjectIdentifier(this.bytes);
+}
+
+class ASN1Null extends ASN1Object {}
diff --git a/googleapis_auth/lib/src/crypto/pem.dart b/googleapis_auth/lib/src/crypto/pem.dart
new file mode 100644
index 0000000..a01ba5e
--- /dev/null
+++ b/googleapis_auth/lib/src/crypto/pem.dart
@@ -0,0 +1,214 @@
+// 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.
+
+library googleapis_auth.pem;
+
+import 'dart:convert';
+import 'dart:typed_data';
+
+import 'asn1.dart';
+import 'rsa.dart';
+
+/// Decode a [RSAPrivateKey] from the string content of a PEM file.
+///
+/// A PEM file can be extracted from a .p12 cryptostore with
+/// $ openssl pkcs12 -nocerts -nodes -passin pass:notasecret \
+///       -in *-privatekey.p12 -out *-privatekey.pem
+RSAPrivateKey keyFromString(String pemFileString) {
+  final bytes = _getBytesFromPEMString(pemFileString);
+  return _extractRSAKeyFromDERBytes(bytes);
+}
+
+/// Helper function for decoding the base64 in [pemString].
+Uint8List _getBytesFromPEMString(String pemString) {
+  final lines = LineSplitter.split(pemString)
+      .map((line) => line.trim())
+      .where((line) => line.isNotEmpty)
+      .toList();
+
+  if (lines.length < 2 ||
+      !lines.first.startsWith('-----BEGIN') ||
+      !lines.last.startsWith('-----END')) {
+    throw ArgumentError('The given string does not have the correct '
+        'begin/end markers expected in a PEM file.');
+  }
+  final base64 = lines.sublist(1, lines.length - 1).join();
+  return Uint8List.fromList(base64Decode(base64));
+}
+
+/// Helper to decode the ASN.1/DER bytes in [bytes] into an [RSAPrivateKey].
+RSAPrivateKey _extractRSAKeyFromDERBytes(Uint8List bytes) {
+  // We recognize two formats:
+  // Real format:
+  //
+  // PrivateKey := seq[int/version=0, int/n, int/e, int/d, int/p,
+  //                   int/q, int/dmp1, int/dmq1, int/coeff]
+  //
+  // Or the above `PrivateKey` embeddded inside another ASN object:
+  // Encapsulated := seq[int/version=0,
+  //                     seq[obj-id/rsa-id, null-obj],
+  //                     octet-string/PrivateKey]
+  //
+
+  RSAPrivateKey privateKeyFromSequence(ASN1Sequence asnSequence) {
+    final objects = asnSequence.objects;
+
+    final asnIntegers = objects.take(9).map((o) => o as ASN1Integer).toList();
+
+    final version = asnIntegers.first;
+    if (version.integer != BigInt.zero) {
+      throw ArgumentError('Expected version 0, got: ${version.integer}.');
+    }
+
+    final key = RSAPrivateKey(
+        asnIntegers[1].integer,
+        asnIntegers[2].integer,
+        asnIntegers[3].integer,
+        asnIntegers[4].integer,
+        asnIntegers[5].integer,
+        asnIntegers[6].integer,
+        asnIntegers[7].integer,
+        asnIntegers[8].integer);
+
+    final bitLength = key.bitLength;
+    if (bitLength != 1024 && bitLength != 2048 && bitLength != 4096) {
+      throw ArgumentError('The RSA modulus has a bit length of $bitLength. '
+          'Only 1024, 2048 and 4096 are supported.');
+    }
+    return key;
+  }
+
+  try {
+    final asn = ASN1Parser.parse(bytes);
+    if (asn is ASN1Sequence) {
+      final objects = asn.objects;
+      if (objects.length == 3 && objects[2] is ASN1OctetString) {
+        final string = objects[2] as ASN1OctetString;
+        // Seems like the embedded form.
+        // TODO: Validate that rsa identifier matches!
+        return privateKeyFromSequence(
+            ASN1Parser.parse(string.bytes as Uint8List) as ASN1Sequence);
+      }
+    }
+    return privateKeyFromSequence(asn as ASN1Sequence);
+  } catch (error) {
+    throw ArgumentError(
+        'Error while extracting private key from DER bytes: $error');
+  }
+}
+
+/*
+ * Example of generating a public/private RSA keypair with 2048 bits and dumping
+ * the structure of the resulting private key.
+
+    $ openssl genrsa -out key.pem 2048
+      Generating RSA private key, 2048 bit long modulus
+      ..................................................+++
+      ..................................................+++
+      e is 65537 (0x10001)
+
+    $ cat key.pem
+      -----BEGIN RSA PRIVATE KEY-----
+      MIIEowIBAAKCAQEAuDOwXO14ltE1j2O0iDSuqtbw/1kMKjeiki3oehk2zNoUte42
+      /s2rX15nYCkKtYG/r8WYvKzb31P4Uow1S4fFydKNWxgX4VtEjHgeqfPxeCL9wiJc
+      9KkEt4fyhj1Jo7193gCLtovLAFwPzAMbFLiXWkfqalJ5Z77fOE4Mo7u4pEgxNPgL
+      VFGe0cEOAsHsKlsze+m1pmPHwWNVTcoKe5o0hOzy6hCPgVc6me6Y7aO8Fb4OVg0l
+      XQdQpWn2ikVBpzBcZ6InnYyJ/CJNa3WL1LJ65mmYnfHtKGoMqhLK48OReguwRwwF
+      e9/2+8UcdZcN5rsvt7yg3ZrKNH8rx+wZ36sRewIDAQABAoIBAQCn1HCcOsHkqDlk
+      rDOQ5m8+uRhbj4bF8GrvRWTL2q1TeF/mY2U4Q6wg+KK3uq1HMzCzthWz0suCb7+R
+      dq4YY1ySxoSEuy8G5WFPmyJVNy6Lh1Yty6FmSZlCn1sZdD3kMoK8A0NIz5Xmffrm
+      pu3Fs2ozl9K9jOeQ3xgC9RoPFLrm8lHJ45Vn+SnTxZnsXT6pwpg3TnFIx5ZinU8k
+      l0Um1n80qD2QQDakQ5jyr2odAELLBDlyCkxAglBXAVt4nk9Kl6nxb4snd9dnrL70
+      WjLynWQsDczaV9TZIl2hYkMud+9OLVlUUtB+0c5b0p2t2P0sLltDaq3H6pT6yu2G
+      8E86J9IBAoGBAPJaTNV5ysVOFn+YwWwRztzrvNArUJkVq8abN0gGp3gUvDEZnvzK
+      weF7+lfZzcwVRmQkL3mWLzzZvCx77RfulAzLi5iFuRBPhhhxAPDiDuyL9B7O81G/
+      M/W5DPctGOyD/9cnLuh72oij0unc5MLSfzJf8wblpcjJnPBDqIVh6Qt9AoGBAMKT
+      Gacf4iSj1xW+0wrnbZlDuyCl6Msptj8ePcvLQrFqQmBwsXmWgVR+gFc/1G3lRft0
+      QC6chsmafQHIIPpaDjq3sQ01/tUu7LXL+g/Hw9XtUHbkg3sZIQBtC26rKdStfHNS
+      KTvuCgn/dAJNjiohfhWMt9R4Q6E5FV6PqQHJzPJXAoGAC41qZDKuC8GxKNvrPG+M
+      4NML6RBngySZT5pOhExs5zh10BFclshDfbAfOtjTCotpE5T1/mG+VrQ6WBSANMfW
+      ntWFDfwx2ikwRzH7zX+5HmV9eYp75sWqgGgVyiKIMZ4JMARaJBLjU+gbQbKZ5P+L
+      uKcCOq3vvSZ/KKTQ/6qvJTECgYBiWgbCgoxF5wdmd4Gn5llw+lqRYyur3hbACuJD
+      rCe3FDYfF3euNRSEiDkJYTtYnWbldtqmdPpw14VOrEF3KqQ8q/Nz8RIx4jlGn6dz
+      6I8mCIH+xv1q8MXMuFHqC9zmIxdgF2y+XVF3wkd6jodI5oscC3g0juHokbkqhkVw
+      oPfWmwKBgBfR6jv0gWWeWTfkNwj+cMLHQV1uvz6JyLH5K4iISEDFxYkd37jrHB8A
+      9hz9UDfmCbSs2j8CXDg7zCayM6tfu4Vtx+8S5g3oN6sa1JXFY1Os7SoXhTfX9M+7
+      QpYYDJZwkgZrVQoKMIdCs9xfyVhZERq945NYLekwE1t2W+tOVBgR
+      -----END RSA PRIVATE KEY-----
+
+    $ openssl enc -d -base64 -in key.pem -out key.bin
+
+    $ dumpasn1 key.bin
+         0 1187: SEQUENCE {
+         4    1:   INTEGER 0
+         7  257:   INTEGER
+               :     00 B8 33 B0 5C ED 78 96 D1 35 8F 63 B4 88 34 AE
+               :     AA D6 F0 FF 59 0C 2A 37 A2 92 2D E8 7A 19 36 CC
+               :     DA 14 B5 EE 36 FE CD AB 5F 5E 67 60 29 0A B5 81
+               :     BF AF C5 98 BC AC DB DF 53 F8 52 8C 35 4B 87 C5
+               :     C9 D2 8D 5B 18 17 E1 5B 44 8C 78 1E A9 F3 F1 78
+               :     22 FD C2 22 5C F4 A9 04 B7 87 F2 86 3D 49 A3 BD
+               :     7D DE 00 8B B6 8B CB 00 5C 0F CC 03 1B 14 B8 97
+               :     5A 47 EA 6A 52 79 67 BE DF 38 4E 0C A3 BB B8 A4
+               :             [ Another 129 bytes skipped ]
+       268    3:   INTEGER 65537
+       273  257:   INTEGER
+               :     00 A7 D4 70 9C 3A C1 E4 A8 39 64 AC 33 90 E6 6F
+               :     3E B9 18 5B 8F 86 C5 F0 6A EF 45 64 CB DA AD 53
+               :     78 5F E6 63 65 38 43 AC 20 F8 A2 B7 BA AD 47 33
+               :     30 B3 B6 15 B3 D2 CB 82 6F BF 91 76 AE 18 63 5C
+               :     92 C6 84 84 BB 2F 06 E5 61 4F 9B 22 55 37 2E 8B
+               :     87 56 2D CB A1 66 49 99 42 9F 5B 19 74 3D E4 32
+               :     82 BC 03 43 48 CF 95 E6 7D FA E6 A6 ED C5 B3 6A
+               :     33 97 D2 BD 8C E7 90 DF 18 02 F5 1A 0F 14 BA E6
+               :             [ Another 129 bytes skipped ]
+       534  129:   INTEGER
+               :     00 F2 5A 4C D5 79 CA C5 4E 16 7F 98 C1 6C 11 CE
+               :     DC EB BC D0 2B 50 99 15 AB C6 9B 37 48 06 A7 78
+               :     14 BC 31 19 9E FC CA C1 E1 7B FA 57 D9 CD CC 15
+               :     46 64 24 2F 79 96 2F 3C D9 BC 2C 7B ED 17 EE 94
+               :     0C CB 8B 98 85 B9 10 4F 86 18 71 00 F0 E2 0E EC
+               :     8B F4 1E CE F3 51 BF 33 F5 B9 0C F7 2D 18 EC 83
+               :     FF D7 27 2E E8 7B DA 88 A3 D2 E9 DC E4 C2 D2 7F
+               :     32 5F F3 06 E5 A5 C8 C9 9C F0 43 A8 85 61 E9 0B
+               :             [ Another 1 bytes skipped ]
+       666  129:   INTEGER
+               :     00 C2 93 19 A7 1F E2 24 A3 D7 15 BE D3 0A E7 6D
+               :     99 43 BB 20 A5 E8 CB 29 B6 3F 1E 3D CB CB 42 B1
+               :     6A 42 60 70 B1 79 96 81 54 7E 80 57 3F D4 6D E5
+               :     45 FB 74 40 2E 9C 86 C9 9A 7D 01 C8 20 FA 5A 0E
+               :     3A B7 B1 0D 35 FE D5 2E EC B5 CB FA 0F C7 C3 D5
+               :     ED 50 76 E4 83 7B 19 21 00 6D 0B 6E AB 29 D4 AD
+               :     7C 73 52 29 3B EE 0A 09 FF 74 02 4D 8E 2A 21 7E
+               :     15 8C B7 D4 78 43 A1 39 15 5E 8F A9 01 C9 CC F2
+               :             [ Another 1 bytes skipped ]
+       798  128:   INTEGER
+               :     0B 8D 6A 64 32 AE 0B C1 B1 28 DB EB 3C 6F 8C E0
+               :     D3 0B E9 10 67 83 24 99 4F 9A 4E 84 4C 6C E7 38
+               :     75 D0 11 5C 96 C8 43 7D B0 1F 3A D8 D3 0A 8B 69
+               :     13 94 F5 FE 61 BE 56 B4 3A 58 14 80 34 C7 D6 9E
+               :     D5 85 0D FC 31 DA 29 30 47 31 FB CD 7F B9 1E 65
+               :     7D 79 8A 7B E6 C5 AA 80 68 15 CA 22 88 31 9E 09
+               :     30 04 5A 24 12 E3 53 E8 1B 41 B2 99 E4 FF 8B B8
+               :     A7 02 3A AD EF BD 26 7F 28 A4 D0 FF AA AF 25 31
+       929  128:   INTEGER
+               :     62 5A 06 C2 82 8C 45 E7 07 66 77 81 A7 E6 59 70
+               :     FA 5A 91 63 2B AB DE 16 C0 0A E2 43 AC 27 B7 14
+               :     36 1F 17 77 AE 35 14 84 88 39 09 61 3B 58 9D 66
+               :     E5 76 DA A6 74 FA 70 D7 85 4E AC 41 77 2A A4 3C
+               :     AB F3 73 F1 12 31 E2 39 46 9F A7 73 E8 8F 26 08
+               :     81 FE C6 FD 6A F0 C5 CC B8 51 EA 0B DC E6 23 17
+               :     60 17 6C BE 5D 51 77 C2 47 7A 8E 87 48 E6 8B 1C
+               :     0B 78 34 8E E1 E8 91 B9 2A 86 45 70 A0 F7 D6 9B
+      1060  128:   INTEGER
+               :     17 D1 EA 3B F4 81 65 9E 59 37 E4 37 08 FE 70 C2
+               :     C7 41 5D 6E BF 3E 89 C8 B1 F9 2B 88 88 48 40 C5
+               :     C5 89 1D DF B8 EB 1C 1F 00 F6 1C FD 50 37 E6 09
+               :     B4 AC DA 3F 02 5C 38 3B CC 26 B2 33 AB 5F BB 85
+               :     6D C7 EF 12 E6 0D E8 37 AB 1A D4 95 C5 63 53 AC
+               :     ED 2A 17 85 37 D7 F4 CF BB 42 96 18 0C 96 70 92
+               :     06 6B 55 0A 0A 30 87 42 B3 DC 5F C9 58 59 11 1A
+               :     BD E3 93 58 2D E9 30 13 5B 76 5B EB 4E 54 18 11
+               :   }
+ */
diff --git a/googleapis_auth/lib/src/crypto/rsa.dart b/googleapis_auth/lib/src/crypto/rsa.dart
new file mode 100644
index 0000000..8c57660
--- /dev/null
+++ b/googleapis_auth/lib/src/crypto/rsa.dart
@@ -0,0 +1,111 @@
+// 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.
+
+// A small part is based on a JavaScript implementation of RSA by Tom Wu
+// but re-written in dart.
+
+library googleapis_auth.rsa;
+
+import 'dart:typed_data';
+
+/// Represents integers obtained while creating a Public/Private key pair.
+class RSAPrivateKey {
+  /// First prime number.
+  final BigInt p;
+
+  /// Second prime number.
+  final BigInt q;
+
+  /// Modulus for public and private keys. Satisfies `n=p*q`.
+  final BigInt n;
+
+  /// Public key exponent. Satisfies `d*e=1 mod phi(n)`.
+  final BigInt e;
+
+  /// Private key exponent. Satisfies `d*e=1 mod phi(n)`.
+  final BigInt d;
+
+  /// Different form of [p]. Satisfies `dmp1=d mod (p-1)`.
+  final BigInt dmp1;
+
+  /// Different form of [p]. Satisfies `dmq1=d mod (q-1)`.
+  final BigInt dmq1;
+
+  /// A coefficient which satisfies `coeff=q^-1 mod p`.
+  final BigInt coeff;
+
+  /// The number of bits used for the modulus. Usually 1024, 2048 or 4096 bits.
+  int get bitLength => n.bitLength;
+
+  RSAPrivateKey(
+      this.n, this.e, this.d, this.p, this.q, this.dmp1, this.dmq1, this.coeff);
+}
+
+/// Provides a [encrypt] method for encrypting messages with a [RSAPrivateKey].
+abstract class RSAAlgorithm {
+  /// Performs the encryption of [bytes] with the private [key].
+  /// Others who have access to the public key will be able to decrypt this
+  /// the result.
+  ///
+  /// The [intendedLength] argument specifies the number of bytes in which the
+  /// result should be encoded. Zero bytes will be used for padding.
+  static List<int> encrypt(
+      RSAPrivateKey key, List<int> bytes, int intendedLength) {
+    final message = bytes2BigInt(bytes);
+    final encryptedMessage = _encryptInteger(key, message);
+    return integer2Bytes(encryptedMessage, intendedLength);
+  }
+
+  static BigInt _encryptInteger(RSAPrivateKey key, BigInt x) {
+    // The following is equivalent to `_modPow(x, key.d, key.n) but is much
+    // more efficient. It exploits the fact that we have dmp1/dmq1.
+    var xp = _modPow(x % key.p, key.dmp1, key.p);
+    final xq = _modPow(x % key.q, key.dmq1, key.q);
+    while (xp < xq) {
+      xp += key.p;
+    }
+    return ((((xp - xq) * key.coeff) % key.p) * key.q) + xq;
+  }
+
+  // TODO(kevmoo): see if this can be done more efficiently with BigInt
+  static BigInt _modPow(BigInt b, BigInt e, BigInt m) {
+    if (e < BigInt.one) {
+      return BigInt.one;
+    }
+    if (b < BigInt.zero || b > m) {
+      b = b % m;
+    }
+    var r = BigInt.one;
+    while (e > BigInt.zero) {
+      if ((e & BigInt.one) > BigInt.zero) {
+        r = (r * b) % m;
+      }
+      e >>= 1;
+      b = (b * b) % m;
+    }
+    return r;
+  }
+
+  static BigInt bytes2BigInt(List<int> bytes) {
+    var number = BigInt.zero;
+    for (var i = 0; i < bytes.length; i++) {
+      number = (number << 8) | BigInt.from(bytes[i]);
+    }
+    return number;
+  }
+
+  static List<int> integer2Bytes(BigInt integer, int intendedLength) {
+    if (integer < BigInt.one) {
+      throw ArgumentError('Only positive integers are supported.');
+    }
+    final bytes = Uint8List(intendedLength);
+    for (var i = bytes.length - 1; i >= 0; i--) {
+      bytes[i] = (integer & _bigIntFF).toInt();
+      integer >>= 8;
+    }
+    return bytes;
+  }
+}
+
+final _bigIntFF = BigInt.from(0xff);
diff --git a/googleapis_auth/lib/src/crypto/rsa_sign.dart b/googleapis_auth/lib/src/crypto/rsa_sign.dart
new file mode 100644
index 0000000..842bc78
--- /dev/null
+++ b/googleapis_auth/lib/src/crypto/rsa_sign.dart
@@ -0,0 +1,81 @@
+// 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.
+
+library googleapis_auth.rsa_sign;
+
+import 'dart:typed_data';
+
+import 'package:crypto/crypto.dart';
+
+import 'asn1.dart';
+import 'rsa.dart';
+
+/// Used for signing messages with a private RSA key.
+///
+/// The implemented algorithm can be seen in
+/// RFC 3447, Section 9.2 EMSA-PKCS1-v1_5.
+class RS256Signer {
+  // NIST sha-256 OID (2 16 840 1 101 3 4 2 1)
+  // See a reference for the encoding here:
+  // http://msdn.microsoft.com/en-us/library/bb540809%28v=vs.85%29.aspx
+  static const _rsaSha256AlgorithmIdentifier = [
+    0x06,
+    0x09,
+    0x60,
+    0x86,
+    0x48,
+    0x01,
+    0x65,
+    0x03,
+    0x04,
+    0x02,
+    0x01
+  ];
+
+  final RSAPrivateKey _rsaKey;
+
+  RS256Signer(this._rsaKey);
+
+  List<int> sign(List<int> bytes) {
+    final digest = _digestInfo(sha256.convert(bytes).bytes);
+    final modulusLen = (_rsaKey.bitLength + 7) ~/ 8;
+
+    final block = Uint8List(modulusLen);
+    final padLength = block.length - digest.length - 3;
+    block[0] = 0x00;
+    block[1] = 0x01;
+    block.fillRange(2, 2 + padLength, 0xFF);
+    block[2 + padLength] = 0x00;
+    block.setRange(2 + padLength + 1, block.length, digest);
+    return RSAAlgorithm.encrypt(_rsaKey, block, modulusLen);
+  }
+
+  static Uint8List _digestInfo(List<int> hash) {
+    // DigestInfo :== SEQUENCE {
+    //     digestAlgorithm AlgorithmIdentifier,
+    //     digest OCTET STRING
+    // }
+    var offset = 0;
+    final digestInfo = Uint8List(
+        2 + 2 + _rsaSha256AlgorithmIdentifier.length + 2 + 2 + hash.length);
+    {
+      // DigestInfo
+      digestInfo[offset++] = ASN1Parser.sequenceTag;
+      digestInfo[offset++] = digestInfo.length - 2;
+      {
+        // AlgorithmIdentifier.
+        digestInfo[offset++] = ASN1Parser.sequenceTag;
+        digestInfo[offset++] = _rsaSha256AlgorithmIdentifier.length + 2;
+        digestInfo.setAll(offset, _rsaSha256AlgorithmIdentifier);
+        offset += _rsaSha256AlgorithmIdentifier.length;
+        digestInfo[offset++] = ASN1Parser.nullTag;
+        digestInfo[offset++] = 0;
+      }
+      digestInfo[offset++] = ASN1Parser.octetStringTag;
+      digestInfo[offset++] = hash.length;
+      digestInfo.setAll(offset, hash);
+    }
+    return digestInfo;
+  }
+}
diff --git a/googleapis_auth/lib/src/http_client_base.dart b/googleapis_auth/lib/src/http_client_base.dart
new file mode 100644
index 0000000..60a9f3f
--- /dev/null
+++ b/googleapis_auth/lib/src/http_client_base.dart
@@ -0,0 +1,105 @@
+// 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.
+
+library googleapis_auth.http_client_base;
+
+import 'dart:async';
+
+import 'package:http/http.dart';
+
+/// Base class for delegating HTTP clients.
+///
+/// Depending on [closeUnderlyingClient] it will close the client it is
+/// delegating to or not.
+abstract class DelegatingClient extends BaseClient {
+  final Client baseClient;
+  final bool closeUnderlyingClient;
+  bool _isClosed = false;
+
+  DelegatingClient(this.baseClient, {this.closeUnderlyingClient = true});
+
+  @override
+  void close() {
+    if (_isClosed) {
+      throw StateError('Cannot close a HTTP client more than once.');
+    }
+    _isClosed = true;
+    super.close();
+
+    if (closeUnderlyingClient) {
+      baseClient.close();
+    }
+  }
+}
+
+/// A reference counted HTTP client.
+///
+/// It uses a base [Client] which will be closed once the reference count
+/// reaches zero. The initial reference count is one, since the caller has a
+/// reference to the constructed instance.
+class RefCountedClient extends DelegatingClient {
+  int _refCount;
+
+  RefCountedClient(Client baseClient, {int initialRefCount = 1})
+      : _refCount = initialRefCount,
+        super(baseClient, closeUnderlyingClient: true);
+
+  @override
+  Future<StreamedResponse> send(BaseRequest request) {
+    _ensureClientIsOpen();
+    return baseClient.send(request);
+  }
+
+  /// Acquires a new reference which causes the reference count to be
+  /// incremented by 1.
+  void acquire() {
+    _ensureClientIsOpen();
+    _refCount++;
+  }
+
+  /// Releases a new reference which causes the reference count to be
+  /// decremented by 1.
+  void release() {
+    _ensureClientIsOpen();
+    _refCount--;
+
+    if (_refCount == 0) {
+      super.close();
+    }
+  }
+
+  /// Is equivalent to calling `release`.
+  @override
+  void close() {
+    release();
+  }
+
+  void _ensureClientIsOpen() {
+    if (_refCount <= 0) {
+      throw StateError(
+          'This reference counted HTTP client has reached a count of zero and '
+          'can no longer be used for making HTTP requests.');
+    }
+  }
+}
+
+// NOTE:
+// Calling close on the returned client once will not close the underlying
+// [baseClient].
+Client nonClosingClient(Client baseClient) =>
+    RefCountedClient(baseClient, initialRefCount: 2);
+
+class RequestImpl extends BaseRequest {
+  final Stream<List<int>> _stream;
+
+  RequestImpl(String method, Uri url, [Stream<List<int>>? stream])
+      : _stream = stream ?? const Stream.empty(),
+        super(method, url);
+
+  @override
+  ByteStream finalize() {
+    super.finalize();
+    return ByteStream(_stream);
+  }
+}
diff --git a/googleapis_auth/lib/src/oauth2_flows/auth_code.dart b/googleapis_auth/lib/src/oauth2_flows/auth_code.dart
new file mode 100644
index 0000000..e5a2b8c
--- /dev/null
+++ b/googleapis_auth/lib/src/oauth2_flows/auth_code.dart
@@ -0,0 +1,272 @@
+// 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.
+
+library googleapis_auth.auth_code_flow;
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:http/http.dart' as http;
+
+import '../../auth.dart';
+import '../http_client_base.dart';
+import '../typedefs.dart';
+import '../utils.dart';
+
+// The OAuth2 Token endpoint can be used to make requests as
+//    https://www.googleapis.com/oauth2/v2/tokeninfo?access_token=<token>
+//
+// A successfull response from the server will give an HTTP response status
+// 200 and a body of the following type:
+// {
+//   "issued_to": "XYZ.apps.googleusercontent.com",
+//   "audience": "XYZ.apps.googleusercontent.com",
+//   "scope": "https://www.googleapis.com/auth/bigquery",
+//   "expires_in": 3547,
+//   "access_type": "offline"
+// }
+//
+// Scopes are separated by spaces.
+Future<List<String>> obtainScopesFromAccessToken(
+    String accessToken, http.Client client) async {
+  final url = Uri.parse('https://www.googleapis.com/oauth2/v2/tokeninfo'
+      '?access_token=${Uri.encodeQueryComponent(accessToken)}');
+
+  final response = await client.post(url);
+  if (response.statusCode == 200) {
+    final Map json = jsonDecode(response.body);
+    final scope = json['scope'];
+    if (scope is! String) {
+      throw Exception(
+          'The response did not include a `scope` value of type `String`.');
+    }
+    return scope.split(' ').toList();
+  } else {
+    throw Exception('Unable to obtain list of scopes an access token '
+        'is valid for. Server responded with ${response.statusCode}.');
+  }
+}
+
+Future<AccessCredentials> obtainAccessCredentialsUsingCode(
+    ClientId clientId, String code, String redirectUrl, http.Client client,
+    [List<String>? scopes]) async {
+  final uri = Uri.parse('https://accounts.google.com/o/oauth2/token');
+  final formValues = [
+    'grant_type=authorization_code',
+    'code=${Uri.encodeQueryComponent(code)}',
+    'redirect_uri=${Uri.encodeQueryComponent(redirectUrl)}',
+    'client_id=${Uri.encodeQueryComponent(clientId.identifier)}',
+    'client_secret=${Uri.encodeQueryComponent(clientId.secret!)}',
+  ];
+
+  final body = Stream<List<int>>.fromIterable(
+      <List<int>>[ascii.encode(formValues.join('&'))]);
+  final request = RequestImpl('POST', uri, body);
+  request.headers['content-type'] = contentTypeUrlEncoded;
+
+  final response = await client.send(request);
+  final jsonMap = await utf8.decoder
+      .bind(response.stream)
+      .transform(json.decoder)
+      .first as Map;
+
+  final idToken = jsonMap['id_token'];
+  final tokenType = jsonMap['token_type'];
+  final accessToken = jsonMap['access_token'];
+  final seconds = jsonMap['expires_in'];
+  final refreshToken = jsonMap['refresh_token'];
+  final error = jsonMap['error'];
+
+  if (response.statusCode != 200 && error != null) {
+    throw Exception('Failed to exchange authorization code. '
+        'Response was ${response.statusCode}. Error message was $error.');
+  }
+
+  if (response.statusCode != 200 ||
+      accessToken == null ||
+      seconds is! int ||
+      tokenType != 'Bearer') {
+    throw Exception('Failed to exchange authorization code. '
+        'Invalid server response. '
+        'Http status code was: ${response.statusCode}.');
+  }
+
+  if (scopes != null) {
+    return AccessCredentials(
+        AccessToken('Bearer', accessToken, expiryDate(seconds)),
+        refreshToken,
+        scopes,
+        idToken: idToken);
+  }
+
+  scopes = await obtainScopesFromAccessToken(accessToken, client);
+  return AccessCredentials(
+      AccessToken('Bearer', accessToken, expiryDate(seconds)),
+      refreshToken,
+      scopes,
+      idToken: idToken);
+}
+
+/// Abstract class for obtaining access credentials via the authorization code
+/// grant flow
+///
+/// See
+///   * [AuthorizationCodeGrantServerFlow]
+///   * [AuthorizationCodeGrantManualFlow]
+/// for further details.
+abstract class AuthorizationCodeGrantAbstractFlow {
+  final ClientId clientId;
+  final List<String> scopes;
+  final http.Client _client;
+
+  AuthorizationCodeGrantAbstractFlow(this.clientId, this.scopes, this._client);
+
+  Future<AccessCredentials> run();
+
+  Future<AccessCredentials> _obtainAccessCredentialsUsingCode(
+          String code, String redirectUri) =>
+      obtainAccessCredentialsUsingCode(
+          clientId, code, redirectUri, _client, scopes);
+
+  String _authenticationUri(String redirectUri, {String? state}) {
+    // TODO: Increase scopes with [include_granted_scopes].
+    final queryValues = [
+      'response_type=code',
+      'client_id=${Uri.encodeQueryComponent(clientId.identifier)}',
+      'redirect_uri=${Uri.encodeQueryComponent(redirectUri)}',
+      'scope=${Uri.encodeQueryComponent(scopes.join(' '))}',
+    ];
+    if (state != null) {
+      queryValues.add('state=${Uri.encodeQueryComponent(state)}');
+    }
+    return Uri.parse('https://accounts.google.com/o/oauth2/auth'
+            '?${queryValues.join('&')}')
+        .toString();
+  }
+}
+
+/// Runs an oauth2 authorization code grant flow using an HTTP server.
+///
+/// This class is able to run an oauth2 authorization flow. It takes a user
+/// supplied function which will be called with an URI. The user is expected
+/// to navigate to that URI and to grant access to the client.
+///
+/// Once the user has granted access to the client, Google will redirect the
+/// user agent to a URL pointing to a locally running HTTP server. Which in turn
+/// will be able to extract the authorization code from the URL and use it to
+/// obtain access credentials.
+class AuthorizationCodeGrantServerFlow
+    extends AuthorizationCodeGrantAbstractFlow {
+  final PromptUserForConsent userPrompt;
+
+  AuthorizationCodeGrantServerFlow(ClientId clientId, List<String> scopes,
+      http.Client client, this.userPrompt)
+      : super(clientId, scopes, client);
+
+  @override
+  Future<AccessCredentials> run() async {
+    final server = await HttpServer.bind('localhost', 0);
+
+    try {
+      final port = server.port;
+      final redirectionUri = 'http://localhost:$port';
+      final state = 'authcodestate${DateTime.now().millisecondsSinceEpoch}';
+
+      // Prompt user and wait until he goes to URL and the google authorization
+      // server calls back to our locally running HTTP server.
+      userPrompt(_authenticationUri(redirectionUri, state: state));
+
+      final request = await server.first;
+      final uri = request.uri;
+
+      try {
+        final returnedState = uri.queryParameters['state'];
+        final code = uri.queryParameters['code'];
+        final error = uri.queryParameters['error'];
+
+        if (request.method != 'GET') {
+          throw Exception('Invalid response from server '
+              '(expected GET request callback, got: ${request.method}).');
+        }
+
+        if (state != returnedState) {
+          throw Exception(
+              'Invalid response from server (state did not match).');
+        }
+
+        if (error != null) {
+          throw UserConsentException(
+              'Error occured while obtaining access credentials: $error');
+        }
+
+        if (code == null || code == '') {
+          throw Exception(
+              'Invalid response from server (no auth code transmitted).');
+        }
+        final credentials =
+            await _obtainAccessCredentialsUsingCode(code, redirectionUri);
+
+        // TODO: We could introduce a user-defined redirect page.
+        request.response
+          ..statusCode = 200
+          ..headers.set('content-type', 'text/html; charset=UTF-8')
+          ..write('''
+<!DOCTYPE html>
+
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>Authorization successful.</title>
+  </head>
+
+  <body>
+    <h2 style="text-align: center">Application has successfully obtained access credentials</h2>
+    <p style="text-align: center">This window can be closed now.</p>
+  </body>
+</html>''');
+        await request.response.close();
+        return credentials;
+      } catch (e) {
+        request.response.statusCode = 500;
+        await request.response.close().catchError((_) {});
+        rethrow;
+      }
+    } finally {
+      await server.close();
+    }
+  }
+}
+
+/// Runs an oauth2 authorization code grant flow using manual Copy&Paste.
+///
+/// This class is able to run an oauth2 authorization flow. It takes a user
+/// supplied function which will be called with an URI. The user is expected
+/// to navigate to that URI and to grant access to the client.
+///
+/// Google will give the resource owner a code. The user supplied function needs
+/// to complete with that code.
+///
+/// The authorization code will then be used to obtain access credentials.
+class AuthorizationCodeGrantManualFlow
+    extends AuthorizationCodeGrantAbstractFlow {
+  final PromptUserForConsentManual userPrompt;
+
+  AuthorizationCodeGrantManualFlow(ClientId clientId, List<String> scopes,
+      http.Client client, this.userPrompt)
+      : super(clientId, scopes, client);
+
+  @override
+  Future<AccessCredentials> run() async {
+    const redirectionUri = 'urn:ietf:wg:oauth:2.0:oob';
+
+    // Prompt user and wait until he goes to URL and copy&pastes the auth code
+    // in.
+    final code = await userPrompt(_authenticationUri(redirectionUri));
+    // Use code to obtain credentials
+    return _obtainAccessCredentialsUsingCode(code, redirectionUri);
+  }
+}
+
+// TODO: Server app flow is missing here.
diff --git a/googleapis_auth/lib/src/oauth2_flows/implicit.dart b/googleapis_auth/lib/src/oauth2_flows/implicit.dart
new file mode 100644
index 0000000..4c1dbfe
--- /dev/null
+++ b/googleapis_auth/lib/src/oauth2_flows/implicit.dart
@@ -0,0 +1,261 @@
+// 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.
+
+library googleapis_auth.implicit_gapi_flow;
+
+import 'dart:async';
+import 'dart:html' as html;
+import 'dart:js' as js;
+
+import '../../auth.dart';
+import '../utils.dart';
+
+// This will be overridden by tests.
+String gapiUrl = 'https://apis.google.com/js/client.js';
+
+// According to the CSP3 spec a nonce must be a valid base64 string.
+// https://w3c.github.io/webappsec-csp/#grammardef-base64-value
+final _noncePattern = RegExp('^[\\w+\/_-]+[=]{0,2}\$');
+
+/// This class performs the implicit browser-based oauth2 flow.
+///
+/// It has to be used in two steps:
+///
+/// 1. First call initialize() and wait until the Future completes successfully
+///    - loads the 'gapi' JavaScript library into the current document
+///    - wait until the library signals it is ready
+///
+/// 2. Call login() as often as needed.
+///    - will call the 'gapi' JavaScript lib to trigger an oauth2 browser flow
+///      => This might create a popup which asks the user for consent.
+///    - will wait until the flow is completed (successfully or not)
+///      => Completes with AccessToken or an Exception.
+/// 3. Call loginHybrid() as often as needed.
+///    - will call the 'gapi' JavaScript lib to trigger an oauth2 browser flow
+///      => This might create a popup which asks the user for consent.
+///    - will wait until the flow is completed (successfully or not)
+///      => Completes with a tuple [AccessCredentials cred, String authCode]
+///         or an Exception.
+class ImplicitFlow {
+  static const callbackTimeout = Duration(seconds: 20);
+
+  final String _clientId;
+  final List<String> _scopes;
+
+  /// The pending result of an earlier call to [initialize], if any.
+  ///
+  /// There can be multiple [ImplicitFlow] objects in an application,
+  /// but the gapi JS library should only ever be loaded once. If
+  /// it's called again while a previous initialization is still pending,
+  /// this will be returned.
+  static Future<void>? _pendingInitialization;
+
+  ImplicitFlow(this._clientId, this._scopes);
+
+  /// Readies the flow for calls to [login] by loading the 'gapi'
+  /// JavaScript library, or returning the [Future] of a pending
+  /// initialization if any object has called this method already.
+  Future<void> initialize() {
+    if (_pendingInitialization != null) {
+      return _pendingInitialization!;
+    }
+
+    final completer = Completer();
+
+    final timeout = Timer(callbackTimeout, () {
+      _pendingInitialization = null;
+      completer.completeError(Exception(
+          'Timed out while waiting for the gapi.auth library to load.'));
+    });
+
+    js.context['dartGapiLoaded'] = () {
+      timeout.cancel();
+      try {
+        final gapi = js.context['gapi']['auth'];
+        try {
+          gapi.callMethod('init', [completer.complete]);
+          // ignore: avoid_catching_errors
+        } on NoSuchMethodError {
+          throw StateError('gapi.auth not loaded.');
+        }
+      } catch (error, stack) {
+        _pendingInitialization = null;
+        if (!completer.isCompleted) {
+          completer.completeError(error, stack);
+        }
+      }
+    };
+
+    final script = _createScript();
+    script.src = '$gapiUrl?onload=dartGapiLoaded';
+    script.onError.first.then((errorEvent) {
+      timeout.cancel();
+      _pendingInitialization = null;
+      if (!completer.isCompleted) {
+        // script loading errors can still happen after timeouts
+        completer.completeError(Exception('Failed to load gapi library.'));
+      }
+    });
+    html.document.body!.append(script);
+
+    _pendingInitialization = completer.future;
+    return completer.future;
+  }
+
+  Future<LoginResult> loginHybrid(
+          {bool force = false, bool immediate = false, String? loginHint}) =>
+      _login(force, immediate, true, loginHint, null);
+
+  Future<AccessCredentials> login(
+          {bool force = false,
+          bool immediate = false,
+          String? loginHint,
+          List<ResponseType>? responseTypes}) async =>
+      (await _login(force, immediate, false, loginHint, responseTypes))
+          .credential;
+
+  // Completes with either credentials or a tuple of credentials and authCode.
+  //  hybrid  =>  [AccessCredentials credentials, String authCode]
+  // !hybrid  =>  AccessCredentials
+  //
+  // Alternatively, the response types can be set directly if `hybrid` is not
+  // set to `true`.
+  Future<LoginResult> _login(bool force, bool immediate, bool hybrid,
+      String? loginHint, List<ResponseType>? responseTypes) {
+    assert(hybrid != true || responseTypes?.isNotEmpty != true);
+
+    final completer = Completer<LoginResult>();
+
+    final gapi = js.context['gapi']['auth'];
+
+    final json = {
+      'client_id': _clientId,
+      'immediate': immediate,
+      'approval_prompt': force ? 'force' : 'auto',
+      'response_type': responseTypes?.isNotEmpty == true
+          ? responseTypes!.map(_responseTypeToString).join(' ')
+          : hybrid
+              ? 'code token'
+              : 'token',
+      'scope': _scopes.join(' '),
+      'access_type': hybrid ? 'offline' : 'online',
+    };
+
+    if (loginHint != null) {
+      json['login_hint'] = loginHint;
+    }
+
+    gapi.callMethod('authorize', [
+      js.JsObject.jsify(json),
+      (jsTokenObject) {
+        final tokenType = jsTokenObject['token_type'];
+        final token = jsTokenObject['access_token'];
+        final expiresInRaw = jsTokenObject['expires_in'];
+        final code = jsTokenObject['code'];
+        final error = jsTokenObject['error'];
+        final idToken = jsTokenObject['id_token'];
+
+        int? expiresIn;
+        if (expiresInRaw is String) {
+          expiresIn = int.parse(expiresInRaw);
+        }
+        if (error != null) {
+          completer.completeError(
+            UserConsentException('Failed to get user consent: $error.'),
+          );
+        } else if (token == null ||
+            expiresIn == null ||
+            tokenType != 'Bearer') {
+          completer.completeError(
+            Exception(
+                'Failed to obtain user consent. Invalid server response.'),
+          );
+        } else if (responseTypes?.contains(ResponseType.idToken) == true &&
+            idToken?.isNotEmpty != true) {
+          completer.completeError(
+              Exception('Expected to get id_token, but did not.'));
+        } else {
+          final accessToken =
+              AccessToken('Bearer', token, expiryDate(expiresIn));
+          final credentials =
+              AccessCredentials(accessToken, null, _scopes, idToken: idToken);
+
+          if (hybrid) {
+            if (code == null) {
+              completer.completeError(
+                Exception('Expected to get auth code '
+                    'from server in hybrid flow, but did not.'),
+              );
+              return;
+            }
+            completer.complete(LoginResult(credentials, code: code));
+          } else {
+            completer.complete(LoginResult(credentials));
+          }
+        }
+      }
+    ]);
+
+    return completer.future;
+  }
+}
+
+class LoginResult {
+  final AccessCredentials credential;
+  final String? code;
+
+  LoginResult(this.credential, {this.code});
+}
+
+/// Convert [responseType] to string value expected by `gapi.auth.authorize`.
+String _responseTypeToString(ResponseType responseType) {
+  String result;
+
+  switch (responseType) {
+    case ResponseType.code:
+      result = 'code';
+      break;
+
+    case ResponseType.idToken:
+      result = 'id_token';
+      break;
+
+    case ResponseType.permission:
+      result = 'permission';
+      break;
+
+    case ResponseType.token:
+      result = 'token';
+      break;
+
+    default:
+      throw ArgumentError('Unknown response type: $responseType');
+  }
+
+  return result;
+}
+
+/// Creates a script that will run properly when strict CSP is enforced.
+///
+/// More specifically, the script has the correct `nonce` value set.
+final html.ScriptElement Function() _createScript = (() {
+  final nonce = _getNonce();
+  if (nonce == null) return () => html.ScriptElement();
+
+  return () => html.ScriptElement()..nonce = nonce;
+})();
+
+/// Returns CSP nonce, if set for any script tag.
+String? _getNonce({html.Window? window}) {
+  final currentWindow = window ?? html.window;
+  final elements = currentWindow.document.querySelectorAll('script');
+  for (final element in elements) {
+    final nonceValue =
+        (element as html.HtmlElement).nonce ?? element.attributes['nonce'];
+    if (nonceValue != null && _noncePattern.hasMatch(nonceValue)) {
+      return nonceValue;
+    }
+  }
+  return null;
+}
diff --git a/googleapis_auth/lib/src/oauth2_flows/jwt.dart b/googleapis_auth/lib/src/oauth2_flows/jwt.dart
new file mode 100644
index 0000000..ecc14a4
--- /dev/null
+++ b/googleapis_auth/lib/src/oauth2_flows/jwt.dart
@@ -0,0 +1,96 @@
+// 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.
+
+library jwt_token_generator;
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:http/http.dart' as http;
+
+import '../../auth.dart';
+import '../crypto/rsa.dart';
+import '../crypto/rsa_sign.dart';
+import '../http_client_base.dart';
+import '../utils.dart';
+
+class JwtFlow {
+  // All details are described at:
+  // https://developers.google.com/accounts/docs/OAuth2ServiceAccount
+  // JSON Web Signature (JWS) requires signing a string with a private key.
+
+  static const _googleOauth2TokenUrl =
+      'https://accounts.google.com/o/oauth2/token';
+
+  final String _clientEmail;
+  final RS256Signer _signer;
+  final List<String> _scopes;
+  final String? _user;
+  final http.Client _client;
+
+  JwtFlow(this._clientEmail, RSAPrivateKey key, this._user, this._scopes,
+      this._client)
+      : _signer = RS256Signer(key);
+
+  Future<AccessCredentials> run() async {
+    final timestamp = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000 -
+        maxExpectedTimeDiffInSeconds;
+
+    Map<String, Object> jwtHeader() => {'alg': 'RS256', 'typ': 'JWT'};
+
+    Map<String, Object> jwtClaimSet() {
+      final claimSet = {
+        'iss': _clientEmail,
+        'scope': _scopes.join(' '),
+        'aud': _googleOauth2TokenUrl,
+        'exp': timestamp + 3600,
+        'iat': timestamp,
+        if (_user != null) 'sub': _user!,
+      };
+      return claimSet;
+    }
+
+    final jwtHeaderBase64 = _base64url(ascii.encode(jsonEncode(jwtHeader())));
+    final jwtClaimSetBase64 =
+        _base64url(utf8.encode(jsonEncode(jwtClaimSet())));
+    final jwtSignatureInput = '$jwtHeaderBase64.$jwtClaimSetBase64';
+    final jwtSignatureInputInBytes = ascii.encode(jwtSignatureInput);
+
+    final signature = _signer.sign(jwtSignatureInputInBytes);
+    final jwt = '$jwtSignatureInput.${_base64url(signature)}';
+
+    const uri = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
+    final requestParameters = 'grant_type=${Uri.encodeComponent(uri)}&'
+        'assertion=${Uri.encodeComponent(jwt)}';
+
+    final body = Stream<List<int>>.fromIterable(
+        <List<int>>[utf8.encode(requestParameters)]);
+    final request = RequestImpl('POST', Uri.parse(_googleOauth2TokenUrl), body);
+    request.headers['content-type'] = contentTypeUrlEncoded;
+
+    final httpResponse = await _client.send(request);
+    final response = await httpResponse.stream
+        .transform(utf8.decoder)
+        .transform(json.decoder)
+        .first as Map;
+    final tokenType = response['token_type'];
+    final token = response['access_token'];
+    final expiresIn = response['expires_in'];
+    final error = response['error'];
+
+    if (httpResponse.statusCode != 200 && error != null) {
+      throw Exception('Unable to obtain credentials. Error: $error.');
+    }
+
+    if (tokenType != 'Bearer' || token == null || expiresIn is! int) {
+      throw Exception(
+          'Unable to obtain credentials. Invalid response from server.');
+    }
+    final accessToken = AccessToken(tokenType, token, expiryDate(expiresIn));
+    return AccessCredentials(accessToken, null, _scopes);
+  }
+
+  String _base64url(List<int> bytes) =>
+      base64Url.encode(bytes).replaceAll('=', '');
+}
diff --git a/googleapis_auth/lib/src/oauth2_flows/metadata_server.dart b/googleapis_auth/lib/src/oauth2_flows/metadata_server.dart
new file mode 100644
index 0000000..0330efd
--- /dev/null
+++ b/googleapis_auth/lib/src/oauth2_flows/metadata_server.dart
@@ -0,0 +1,91 @@
+// 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.
+
+library googleapis_auth.metadata_server_flow;
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:http/http.dart' as http;
+
+import '../../auth.dart';
+import '../utils.dart';
+
+/// Obtains access credentials form the metadata server.
+///
+/// Using this class assumes that the current program is running a
+/// ComputeEngine VM. It will retrieve the current access token from the
+/// metadata server, looking first for one set in the environment under
+/// `$GCE_METADATA_HOST`.
+class MetadataServerAuthorizationFlow {
+  static const _headers = {'Metadata-Flavor': 'Google'};
+  static const _serviceAccountUrlInfix =
+      'computeMetadata/v1/instance/service-accounts';
+  static const _defaultMetadataHost = 'metadata';
+  static const _gceMetadataHostEnvVar = 'GCE_METADATA_HOST';
+
+  final String email;
+  final Uri _scopesUrl;
+  final Uri _tokenUrl;
+  final http.Client _client;
+
+  factory MetadataServerAuthorizationFlow(http.Client client,
+      {String email = 'default'}) {
+    final encodedEmail = Uri.encodeComponent(email);
+
+    final metadataHost =
+        Platform.environment[_gceMetadataHostEnvVar] ?? _defaultMetadataHost;
+    final serviceAccountPrefix =
+        'http://$metadataHost/$_serviceAccountUrlInfix';
+
+    final scopesUrl = Uri.parse('$serviceAccountPrefix/$encodedEmail/scopes');
+    final tokenUrl = Uri.parse('$serviceAccountPrefix/$encodedEmail/token');
+    return MetadataServerAuthorizationFlow._(
+        client, email, scopesUrl, tokenUrl);
+  }
+
+  MetadataServerAuthorizationFlow._(
+      this._client, this.email, this._scopesUrl, this._tokenUrl);
+
+  Future<AccessCredentials> run() async {
+    final results = await Future.wait([_getToken(), _getScopes()]);
+    final token = results.first as Map<dynamic, dynamic>;
+    final scopesString = results.last as String;
+
+    final json = token;
+    final scopes = scopesString
+        .replaceAll('\n', ' ')
+        .split(' ')
+        .where((part) => part.isNotEmpty)
+        .toList();
+
+    final type = json['token_type'];
+    final accessToken = json['access_token'];
+    final expiresIn = json['expires_in'];
+    final error = json['error'];
+
+    if (error != null) {
+      throw Exception('Error while obtaining credentials from metadata '
+          'server. Error message: $error.');
+    }
+
+    if (type != 'Bearer' || accessToken == null || expiresIn is! int) {
+      throw Exception('Invalid response from metadata server.');
+    }
+
+    return AccessCredentials(
+        AccessToken(type, accessToken, expiryDate(expiresIn)), null, scopes);
+  }
+
+  Future<Map> _getToken() async {
+    final response = await _client.get(_tokenUrl, headers: _headers);
+    return jsonDecode(response.body);
+  }
+
+  Future<String> _getScopes() async {
+    final response = await _client.get(_scopesUrl, headers: _headers);
+    return response.body;
+  }
+}
diff --git a/googleapis_auth/lib/src/typedefs.dart b/googleapis_auth/lib/src/typedefs.dart
new file mode 100644
index 0000000..78b337e
--- /dev/null
+++ b/googleapis_auth/lib/src/typedefs.dart
@@ -0,0 +1,21 @@
+// 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.
+
+library googleapis_auth.typedefs;
+
+/// Function for directing the user or it's user-agent to [uri].
+///
+/// The user is required to go to [uri] and either approve or decline the
+/// application's request for access resources on his behalf.
+typedef PromptUserForConsent = void Function(String uri);
+
+/// Function for directing the user or it's user-agent to [uri].
+///
+/// The user is required to go to [uri] and either approve or decline the
+/// application's request for access resources on his behalf.
+///
+/// The user will be given an authorization code. This function should complete
+/// with this authorization code. If the user declined to give access this
+/// function should complete with an error.
+typedef PromptUserForConsentManual = Future<String> Function(String uri);
diff --git a/googleapis_auth/lib/src/utils.dart b/googleapis_auth/lib/src/utils.dart
new file mode 100644
index 0000000..84ded61
--- /dev/null
+++ b/googleapis_auth/lib/src/utils.dart
@@ -0,0 +1,19 @@
+// 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.
+
+library googleapis_auth.utils;
+
+/// Due to differences of clock speed, network latency, etc. we
+/// will shorten expiry dates by 20 seconds.
+const maxExpectedTimeDiffInSeconds = 20;
+
+/// 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';
diff --git a/googleapis_auth/mono_pkg.yaml b/googleapis_auth/mono_pkg.yaml
new file mode 100644
index 0000000..528c298
--- /dev/null
+++ b/googleapis_auth/mono_pkg.yaml
@@ -0,0 +1,11 @@
+dart:
+- dev
+
+stages:
+- analyze_and_format:
+  - group:
+    - dartfmt
+    - dartanalyzer: --fatal-infos .
+- unittest:
+  - test: -p vm
+  - test: -p chrome
diff --git a/googleapis_auth/pubspec.yaml b/googleapis_auth/pubspec.yaml
new file mode 100644
index 0000000..6a2fb8f
--- /dev/null
+++ b/googleapis_auth/pubspec.yaml
@@ -0,0 +1,22 @@
+name: googleapis_auth
+version: 0.2.12+2-dev
+description: Obtain Access credentials for Google services using OAuth 2.0
+repository: https://github.com/dart-lang/googleapis
+environment:
+  sdk: '>=2.12.0-0 <3.0.0'
+
+dependencies:
+  crypto: ^3.0.0-nullsafety.0
+  # Not published yet.
+  http: any
+
+dev_dependencies:
+  test: ^1.16.0-nullsafety.9
+
+dependency_overrides:
+  http:
+    git:
+      url: git://github.com/dart-lang/http.git
+      ref: 3845753a54624b070828cb3eff7a6c2a4e046cfb
+  # Needs bump in `packge:shelf` - blocked by pkg:http
+  http_parser: ^4.0.0-nullsafety
diff --git a/googleapis_auth/test/adc_test.dart b/googleapis_auth/test/adc_test.dart
new file mode 100644
index 0000000..efd7ea5
--- /dev/null
+++ b/googleapis_auth/test/adc_test.dart
@@ -0,0 +1,123 @@
+@TestOn('vm')
+library googleapis_auth.adc_test;
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:googleapis_auth/src/adc_utils.dart'
+    show fromApplicationsCredentialsFile;
+import 'package:http/http.dart';
+import 'package:test/test.dart';
+
+import 'test_utils.dart';
+
+void main() {
+  test('fromApplicationsCredentialsFile', () async {
+    final tmp = await Directory.systemTemp.createTemp('googleapis_auth-test');
+    try {
+      final credsFile = File.fromUri(tmp.uri.resolve('creds.json'));
+      await credsFile.writeAsString(json.encode({
+        'client_id': 'id',
+        'client_secret': 'secret',
+        'refresh_token': 'refresh',
+        'type': 'authorized_user'
+      }));
+      final c = await fromApplicationsCredentialsFile(
+        credsFile,
+        'test-credentials-file',
+        [],
+        mockClient((Request request) async {
+          final url = request.url.toString();
+          if (url == 'https://accounts.google.com/o/oauth2/token') {
+            expect(request.method, equals('POST'));
+            expect(
+                request.body,
+                equals('client_id=id&'
+                    'client_secret=secret&'
+                    'refresh_token=refresh&'
+                    'grant_type=refresh_token'));
+            final body = jsonEncode({
+              'token_type': 'Bearer',
+              'access_token': 'atoken',
+              'expires_in': 3600,
+            });
+            return Response(body, 200, headers: _jsonContentType);
+          }
+          if (url == 'https://storage.googleapis.com/b/bucket/o/obj') {
+            expect(request.method, equals('GET'));
+            expect(request.headers['Authorization'], equals('Bearer atoken'));
+            expect(request.headers['X-Goog-User-Project'], isNull);
+            return Response('hello world', 200);
+          }
+          return Response('bad', 404);
+        }, expectClose: false),
+      );
+      expect(c.credentials.accessToken.data, equals('atoken'));
+
+      final r =
+          await c.get(Uri.https('storage.googleapis.com', '/b/bucket/o/obj'));
+      expect(r.statusCode, equals(200));
+      expect(r.body, equals('hello world'));
+
+      c.close();
+    } finally {
+      await tmp.delete(recursive: true);
+    }
+  });
+
+  test('fromApplicationsCredentialsFile w. quota_project_id', () async {
+    final tmp = await Directory.systemTemp.createTemp('googleapis_auth-test');
+    try {
+      final credsFile = File.fromUri(tmp.uri.resolve('creds.json'));
+      await credsFile.writeAsString(json.encode({
+        'client_id': 'id',
+        'client_secret': 'secret',
+        'refresh_token': 'refresh',
+        'type': 'authorized_user',
+        'quota_project_id': 'project'
+      }));
+      final c = await fromApplicationsCredentialsFile(
+        credsFile,
+        'test-credentials-file',
+        [],
+        mockClient((Request request) async {
+          final url = request.url.toString();
+          if (url == 'https://accounts.google.com/o/oauth2/token') {
+            expect(request.method, equals('POST'));
+            expect(
+                request.body,
+                equals('client_id=id&'
+                    'client_secret=secret&'
+                    'refresh_token=refresh&'
+                    'grant_type=refresh_token'));
+            final body = jsonEncode({
+              'token_type': 'Bearer',
+              'access_token': 'atoken',
+              'expires_in': 3600,
+            });
+            return Response(body, 200, headers: _jsonContentType);
+          }
+          if (url == 'https://storage.googleapis.com/b/bucket/o/obj') {
+            expect(request.method, equals('GET'));
+            expect(request.headers['Authorization'], equals('Bearer atoken'));
+            expect(request.headers['X-Goog-User-Project'], equals('project'));
+            return Response('hello world', 200);
+          }
+          return Response('bad', 404);
+        }, expectClose: false),
+      );
+      expect(c.credentials.accessToken.data, equals('atoken'));
+
+      final r =
+          await c.get(Uri.https('storage.googleapis.com', '/b/bucket/o/obj'));
+      expect(r.statusCode, equals(200));
+      expect(r.body, equals('hello world'));
+
+      c.close();
+    } finally {
+      await tmp.delete(recursive: true);
+    }
+  });
+}
+
+const _jsonContentType = {'content-type': 'application/json'};
diff --git a/googleapis_auth/test/crypto/asn1_test.dart b/googleapis_auth/test/crypto/asn1_test.dart
new file mode 100644
index 0000000..cdf7f41
--- /dev/null
+++ b/googleapis_auth/test/crypto/asn1_test.dart
@@ -0,0 +1,150 @@
+// 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.
+
+library googleapis_auth.test.asn1_test;
+
+import 'dart:typed_data';
+
+import 'package:googleapis_auth/src/crypto/asn1.dart';
+import 'package:test/test.dart';
+
+void main() {
+  void expectArgumentError(List<int> bytes) {
+    expect(() => ASN1Parser.parse(Uint8List.fromList(bytes)),
+        throwsA(isArgumentError));
+  }
+
+  void invalidLenTest(int tagBytes) {
+    test('invalid-len', () {
+      expectArgumentError([tagBytes]);
+      expectArgumentError([tagBytes, 0x07]);
+      expectArgumentError([tagBytes, 0x82]);
+      expectArgumentError([tagBytes, 0x82, 1]);
+      expectArgumentError([tagBytes, 0x01, 1, 2, 3, 4]);
+    });
+  }
+
+  group('asn1-parser', () {
+    group('sequence', () {
+      test('empty', () {
+        final sequenceBytes = [ASN1Parser.sequenceTag, 0];
+        final sequence = ASN1Parser.parse(Uint8List.fromList(sequenceBytes));
+        expect(sequence is ASN1Sequence, isTrue);
+        expect((sequence as ASN1Sequence).objects, isEmpty);
+      });
+
+      test('one-element', () {
+        final sequenceBytes = [
+          ASN1Parser.sequenceTag,
+          1,
+          ASN1Parser.nullTag,
+          0
+        ];
+        final sequence = ASN1Parser.parse(Uint8List.fromList(sequenceBytes));
+        expect(sequence is ASN1Sequence, isTrue);
+        expect((sequence as ASN1Sequence).objects, hasLength(1));
+        expect(sequence.objects[0] is ASN1Null, isTrue);
+      });
+
+      test('many-elements', () {
+        final sequenceBytes = [ASN1Parser.sequenceTag, 0x82, 0x01, 0x00];
+        for (var i = 0; i < 128; i++) {
+          sequenceBytes.addAll([ASN1Parser.nullTag, 0]);
+        }
+
+        final sequence = ASN1Parser.parse(Uint8List.fromList(sequenceBytes));
+        expect(sequence is ASN1Sequence, isTrue);
+        expect((sequence as ASN1Sequence).objects.length, equals(128));
+        for (var i = 0; i < 128; i++) {
+          expect(sequence.objects[i] is ASN1Null, isTrue);
+        }
+      });
+
+      invalidLenTest(ASN1Parser.sequenceTag);
+    });
+
+    group('integer', () {
+      test('small', () {
+        for (var i = 0; i < 256; i++) {
+          final integerBytes = [ASN1Parser.integerTag, 1, i];
+          final integer =
+              ASN1Parser.parse(Uint8List.fromList(integerBytes)) as ASN1Integer;
+          expect(integer.integer, BigInt.from(i));
+        }
+      });
+
+      test('multi-byte', () {
+        final integerBytes = [ASN1Parser.integerTag, 3, 1, 2, 3];
+        final integer = ASN1Parser.parse(Uint8List.fromList(integerBytes));
+        expect(integer is ASN1Integer, isTrue);
+        expect((integer as ASN1Integer).integer, BigInt.from(0x010203));
+      });
+
+      invalidLenTest(ASN1Parser.integerTag);
+    });
+
+    group('octet-string', () {
+      test('small', () {
+        final octetStringBytes = [ASN1Parser.octetStringTag, 3, 1, 2, 3];
+        final octetString =
+            ASN1Parser.parse(Uint8List.fromList(octetStringBytes));
+        expect(octetString is ASN1OctetString, isTrue);
+        expect((octetString as ASN1OctetString).bytes, equals([1, 2, 3]));
+      });
+
+      test('large', () {
+        final octetStringBytes = [ASN1Parser.octetStringTag, 0x82, 0x01, 0x00];
+        for (var i = 0; i < 256; i++) {
+          octetStringBytes.add(i % 256);
+        }
+
+        final octetString =
+            ASN1Parser.parse(Uint8List.fromList(octetStringBytes));
+        expect(octetString is ASN1OctetString, isTrue);
+        final castedOctetString = octetString as ASN1OctetString;
+        for (var i = 0; i < 256; i++) {
+          expect(castedOctetString.bytes[i], equals(i % 256));
+        }
+      });
+
+      invalidLenTest(ASN1Parser.octetStringTag);
+    });
+
+    group('oid', () {
+      // NOTE: Currently the oid is parsed as normal bytes, so we don't validate
+      // the oid structure.
+      test('small', () {
+        final objIdBytes = [ASN1Parser.objectIdTag, 3, 1, 2, 3];
+        final objId = ASN1Parser.parse(Uint8List.fromList(objIdBytes));
+        expect(objId is ASN1ObjectIdentifier, isTrue);
+        expect((objId as ASN1ObjectIdentifier).bytes, equals([1, 2, 3]));
+      });
+
+      test('large', () {
+        final objIdBytes = [ASN1Parser.objectIdTag, 0x82, 0x01, 0x00];
+        for (var i = 0; i < 256; i++) {
+          objIdBytes.add(i % 256);
+        }
+
+        final objId = ASN1Parser.parse(Uint8List.fromList(objIdBytes));
+        expect(objId is ASN1ObjectIdentifier, isTrue);
+        final castedObjId = objId as ASN1ObjectIdentifier;
+        for (var i = 0; i < 256; i++) {
+          expect(castedObjId.bytes[i], equals(i % 256));
+        }
+      });
+
+      invalidLenTest(ASN1Parser.objectIdTag);
+    });
+  });
+
+  test('null', () {
+    final objId =
+        ASN1Parser.parse(Uint8List.fromList([ASN1Parser.nullTag, 0x00]));
+    expect(objId is ASN1Null, isTrue);
+
+    expectArgumentError([ASN1Parser.nullTag]);
+    expectArgumentError([ASN1Parser.nullTag, 0x01]);
+  });
+}
diff --git a/googleapis_auth/test/crypto/pem_test.dart b/googleapis_auth/test/crypto/pem_test.dart
new file mode 100644
index 0000000..59ddc17
--- /dev/null
+++ b/googleapis_auth/test/crypto/pem_test.dart
@@ -0,0 +1,51 @@
+// 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.
+
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:googleapis_auth/src/crypto/pem.dart';
+import 'package:test/test.dart';
+
+import '../test_utils.dart';
+
+void main() {
+  group('pem', () {
+    test('null', () {
+      expect(() => keyFromString(''), throwsA(isArgumentError));
+    });
+
+    test('pem--key-from-string', () {
+      final key = keyFromString(testPrivateKeyString);
+      expect(
+          key.p,
+          equals(BigInt.parse(
+              '170185878019789847607218833833962851295383479739128068911675681859184825725303329240997154492057125840628991571181411414164882361723231273391547091096391845233984484218520948165420605211532206383859989286454330226302062891556391372178426684136261758077913279309249468965000813860343415338472623037185763380093')));
+      expect(
+          key.q,
+          equals(BigInt.parse(
+              '136634937867625346722869734066327766542560453705266659651284573193680854438532412351608161985232086174999341126075829838477923122149398705411098928405144549034231120055200290950893136823181693383585861140730929930114638604738429489364496581584222788741142343940831827356789459450282075298628271623617861448279')));
+      expect(
+          key.n,
+          equals(BigInt.parse(
+              '23253336869181252005308127869627478511861722018560725538542603352356752658510633204810959681459083455055115233727694253121121138828979138624495569601457246561359553177524606534054439784597124760679930421448728375265700767584567585959695707287695356045087640902894887625471020788794811661755081070077086649519865067918501869783817745592796089436450623267438942174934673417424553992577792939276705103879103955476795626469391055763713456179432199172562526422301070938382514265029982800538033050279129668807032677927531973249309321914500317007151921938466293582589451642241740444272968677617027011566610435323463337709947')));
+      expect(
+          key.d,
+          equals(BigInt.parse(
+              '21186554940454261253047269959735660724480631477978821785517431853394668885438560354085051566279884512080977781045029208574826211785037495240030508751426142586201712610225510861978099522679761260199887167944008250970681053969661407950094604171122649803382413195502685962008111346880629170494825836648656453852203519401121722270587408277317819537925146228717860401265662699719826243356610955461998054615517371279631680512102389979478015385709644867750888484550190071229275090881149432467365050794063725847869274512118390103343213000471284707060203072264487986083004823016463235156640750689592865369834958756866148520449')));
+      expect(key.e, equals(BigInt.from(65537)));
+      expect(
+          key.dmp1,
+          equals(BigInt.parse(
+              '8112374428701702609593842209702915108210293280208677346843383586799722226617751812699316578927727255231777006398991855865405686833748485558923861522271817820635175987589597358267451526325993144989526626651865780047418167954318425419006133348210655541684866328365584952723843668457708310075048817739114161457')));
+      expect(
+          key.dmq1,
+          equals(BigInt.parse(
+              '69064888333930830841944331910451194321610695483381427808232052980561601308959263072336597373770287299070802348040252301131546443496698520136006747353055884093824470361301555431744464097251017848208627523520965497274938325818544542688522182250340240209771921627903870254182590341478772425006618460954711021211')));
+      expect(
+          key.coeff,
+          equals(BigInt.parse(
+              '16726959063327324857338379758571748557044292252371297447561270320393087678399207080059961434627453370656491757664831584315003981946034135341817305303511530360890203726058358401094205679273808207987503167082629712433452873772120961093571912870024590300080209978748890272607981079166485164486378666155431958545')));
+    });
+  });
+}
diff --git a/googleapis_auth/test/crypto/rsa_sign_test.dart b/googleapis_auth/test/crypto/rsa_sign_test.dart
new file mode 100644
index 0000000..3ee884d
--- /dev/null
+++ b/googleapis_auth/test/crypto/rsa_sign_test.dart
@@ -0,0 +1,90 @@
+// 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.
+
+library googleapis_auth.test.crypto_rsa_sign_test;
+
+import 'dart:convert';
+
+import 'package:googleapis_auth/src/crypto/rsa_sign.dart';
+import 'package:test/test.dart';
+
+import '../test_utils.dart';
+
+void main() {
+  group('rsa-sha256-signer', () {
+    final signer = RS256Signer(testPrivateKey);
+
+    // NOTE:
+    // The signatures can be regenerated via the openssl commandline utility:
+    // $ cat plaintext | openssl dgst -sha256 -sign key.pem > ciphertext
+    // e.g.
+    // $ echo -n "hello world"|openssl dgst -sha256 -sign key.pem|hexdump -v -C
+    // 00000000  59 9d 6f 81 c1 0f d6 f1  58 46 2d 4d c9 b8 69 1d
+    // 00000010  b1 e0 e0 26 a4 de 49 d8  4f 5a ac db 81 ab 10 27
+    // 00000020  3a f4 5a f8 bb da a9 84  be c7 5a fb b9 2e 0a 66
+    // 00000030  8f 78 d5 cb c9 82 0b 57  36 fc bc 42 1b f5 fa 76
+    // 00000040  b7 01 4c bc 2d b9 fe 20  55 62 f5 87 8c bc e3 58
+    // 00000050  a6 c6 8a ef 16 c8 4a 85  01 6e df 05 43 c8 ef 35
+    // 00000060  37 9f 1b 29 57 eb c7 93  89 75 f5 65 81 0a 6c 8c
+    // 00000070  44 35 ad 73 89 90 53 42  26 f3 31 a9 06 f1 32 20
+    // 00000080  48 a3 e1 68 3d 86 67 45  74 19 91 75 c9 28 ca 8b
+    // 00000090  33 63 ed a2 b1 90 e6 e1  0a 1f 87 ec 02 f8 92 03
+    // 000000a0  cf 0e 30 49 b0 f1 72 29  a3 9c 2e cc 7c 87 65 11
+    // 000000b0  1f 38 34 d3 3e fe af 8e  31 f0 10 1f f5 71 dd 90
+    // 000000c0  f6 c7 ba 5d 10 0c 63 eb  a4 3c a5 17 9a 99 52 2d
+    // 000000d0  b6 27 96 8c e2 44 63 35  1f 04 6f b8 31 e6 d4 47
+    // 000000e0  31 0d 3c 36 6c bf 14 df  dc 2d 53 c7 ca d1 ec 6d
+    // 000000f0  95 37 2f 86 14 da 6c 04  a1 fd 45 fa 95 e0 04 bf
+    test('encrypt-hello-world', () {
+      expect(
+          signer.sign(ascii.encode('hello world')),
+          equals([
+            89, 157, 111, 129, 193, 15, 214, 241, 88, 70, 45, 77, 201, 184, //!!
+            105, 29, 177, 224, 224, 38, 164, 222, 73, 216, 79, 90, 172, 219,
+            129, 171, 16, 39, 58, 244, 90, 248, 187, 218, 169, 132, 190, 199,
+            90, 251, 185, 46, 10, 102, 143, 120, 213, 203, 201, 130, 11, 87, 54,
+            252, 188, 66, 27, 245, 250, 118, 183, 1, 76, 188, 45, 185, 254, 32,
+            85, 98, 245, 135, 140, 188, 227, 88, 166, 198, 138, 239, 22,
+            200, 74, 133, 1, 110, 223, 5, 67, 200, 239, 53, 55, 159, 27, 41, 87,
+            235, 199, 147, 137, 117, 245, 101, 129, 10, 108, 140, 68, 53, 173,
+            115, 137, 144, 83, 66, 38, 243, 49, 169, 6, 241, 50, 32, 72, 163,
+            225, 104, 61, 134, 103, 69, 116, 25, 145, 117, 201, 40, 202, 139,
+            51, 99, 237, 162, 177, 144, 230, 225, 10, 31, 135, 236, 2, 248, 146,
+            3, 207, 14, 48, 73, 176, 241, 114, 41, 163, 156, 46, 204, 124, 135,
+            101, 17, 31, 56, 52, 211, 62, 254, 175, 142, 49, 240, 16, 31, 245,
+            113, 221, 144, 246, 199, 186, 93, 16, 12, 99, 235, 164, 60, 165, 23,
+            154, 153, 82, 45, 182, 39, 150, 140, 226, 68, 99, 53, 31, 4, 111,
+            184, 49, 230, 212, 71, 49, 13, 60, 54, 108, 191, 20, 223, 220, 45,
+            83, 199, 202, 209, 236, 109, 149, 55, 47, 134, 20, 218, 108, 4, 161,
+            253, 69, 250, 149, 224, 4, 191
+          ]));
+    });
+
+    // $ echo -n ""|openssl dgst -sha256 -sign key.pem|hexdump -v -C
+    test('null-bytes', () {
+      expect(
+          signer.sign([]),
+          equals([
+            113, 99, 2, 245, 156, 215, 253, 172, 157, 46, 126, 165, 174, //!!
+            158, 186, 213, 211, 85, 118, 63, 208, 122, 196, 214, 154, 221, 92,
+            105, 27, 29, 153, 35, 91, 111, 5, 10, 82, 213, 179, 41, 165, 122,
+            227, 145, 217, 108, 249, 153, 116, 80, 140, 238, 158, 140, 142, 118,
+            224, 10, 225, 58, 77, 210, 27, 66, 177, 165, 228, 40, 225, 211, 140,
+            254, 31, 242, 230, 223, 21, 199, 221, 113, 146, 46, 213, 20, 63,
+            148, 140, 144, 245, 105, 193, 124, 206, 235, 191, 252, 138, 155,
+            148, 175, 185, 160, 98, 102, 156, 197, 29, 80, 202, 49, 26, 173,
+            176, 53, 202, 13, 204, 180, 180, 190, 152, 223, 199, 65, 9, 173, 82,
+            167, 12, 244, 127, 141, 8, 103, 155, 213, 2, 53, 83, 179, 157, 101,
+            190, 205, 85, 58, 50, 89, 255, 11, 67, 18, 232, 252, 229, 197, 200,
+            228, 130, 104, 250, 228, 19, 178, 183, 45, 156, 22, 73, 229, 170,
+            163, 179, 116, 21, 149, 31, 81, 253, 100, 132, 46, 216, 143, 134,
+            185, 96, 75, 57, 139, 21, 131, 114, 221, 124, 47, 104, 92, 235, 254,
+            62, 69, 126, 117, 170, 141, 64, 121, 181, 101, 69, 135, 115, 102,
+            74, 157, 233, 127, 139, 14, 79, 137, 156, 248, 117, 114, 205, 142,
+            60, 8, 116, 77, 182, 28, 119, 149, 143, 252, 141, 46, 111, 100, 242,
+            184, 21, 130, 61, 138, 27, 226, 70, 119, 195, 223, 180, 121
+          ]));
+    });
+  });
+}
diff --git a/googleapis_auth/test/crypto/rsa_test.dart b/googleapis_auth/test/crypto/rsa_test.dart
new file mode 100644
index 0000000..441fa73
--- /dev/null
+++ b/googleapis_auth/test/crypto/rsa_test.dart
@@ -0,0 +1,59 @@
+// 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.
+
+library googleapis_auth.test.rsa_test;
+
+import 'package:googleapis_auth/src/crypto/rsa.dart';
+import 'package:test/test.dart';
+
+import '../test_utils.dart';
+
+/// 2 << 64
+final _bigNumber = BigInt.parse('20000000000000000', radix: 16);
+
+void main() {
+  group('rsa-algorithm', () {
+    test('integer-to-bytes', () {
+      expect(RSAAlgorithm.integer2Bytes(BigInt.one, 1), equals([1]));
+      expect(RSAAlgorithm.integer2Bytes(_bigNumber, 9),
+          equals([2, 0, 0, 0, 0, 0, 0, 0, 0]));
+      expect(RSAAlgorithm.integer2Bytes(_bigNumber, 12),
+          equals([0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0]));
+      expect(() => RSAAlgorithm.integer2Bytes(BigInt.zero, 1),
+          throwsA(isArgumentError));
+    });
+
+    test('bytes-to-integer', () {
+      expect(RSAAlgorithm.bytes2BigInt([1]), equals(BigInt.one));
+      expect(
+          RSAAlgorithm.bytes2BigInt([2, 0, 0, 0, 0, 0, 0, 0, 0]), _bigNumber);
+    });
+
+    test('encrypt', () {
+      final encryptedData = [
+        155, 24, 116, 247, 12, 118, 240, 206, 240, 138, 136, 193, 3, 73, //!!
+        241, 63, 212, 100, 97, 46, 55, 113, 119, 95, 240, 219, 136, 211, 3, 4,
+        43, 137, 213, 92, 233, 57, 172, 80, 179, 117, 83, 88, 249, 75, 17, 20,
+        195, 51, 25, 97, 248, 217, 41, 117, 55, 63, 5, 252, 42, 133, 82, 73, 52,
+        219, 255, 38, 137, 209, 83, 57, 245, 188, 180, 233, 249, 144, 100, 153,
+        145, 14, 94, 2, 229, 165, 131, 178, 195, 178, 95, 244, 153, 196, 130,
+        39, 158, 143, 98, 181, 223, 184, 68, 198, 201, 203, 89, 15, 41, 185,
+        226, 64, 226, 161, 43, 228, 90, 58, 152, 203, 142, 133, 113, 120, 97,
+        78, 149, 86, 214, 135, 29, 29, 190, 16, 47, 210, 1, 213, 86, 100, 116,
+        187, 11, 255, 224, 6, 6, 206, 60, 138, 24, 179, 245, 248, 200, 45, 167,
+        100, 78, 131, 204, 120, 22, 73, 116, 127, 65, 201, 15, 177, 250, 4, 73,
+        245, 67, 119, 21, 54, 255, 227, 206, 37, 216, 13, 8, 109, 238, 215, 22,
+        63, 163, 155, 33, 148, 254, 113, 17, 68, 65, 48, 82, 43, 240, 249, 87,
+        19, 87, 162, 148, 169, 93, 22, 135, 125, 134, 187, 48, 93, 52, 20, 182,
+        56, 93, 0, 175, 193, 213, 144, 29, 44, 240, 226, 91, 54, 178, 241, 240,
+        85, 53, 148, 172, 138, 107, 131, 14, 157, 183, 137, 46, 130, 51, 233,
+        26, 217, 230, 133, 217, 76
+      ];
+      expect(
+          RSAAlgorithm.encrypt(
+              testPrivateKey, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 256),
+          equals(encryptedData));
+    });
+  });
+}
diff --git a/googleapis_auth/test/http_client_base_test.dart b/googleapis_auth/test/http_client_base_test.dart
new file mode 100644
index 0000000..7b0933b
--- /dev/null
+++ b/googleapis_auth/test/http_client_base_test.dart
@@ -0,0 +1,132 @@
+// 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.
+
+library googleapis_auth.http_client_base_test;
+
+import 'dart:async';
+
+import 'package:googleapis_auth/src/auth_http_utils.dart';
+import 'package:googleapis_auth/src/http_client_base.dart';
+import 'package:http/http.dart';
+import 'package:test/test.dart';
+
+import 'test_utils.dart';
+
+class DelegatingClientImpl extends DelegatingClient {
+  DelegatingClientImpl(Client base, {required bool closeUnderlyingClient})
+      : super(base, closeUnderlyingClient: closeUnderlyingClient);
+
+  @override
+  Future<StreamedResponse> send(BaseRequest request) =>
+      throw UnsupportedError('Not supported');
+}
+
+final _defaultResponse = Response('', 500);
+
+Future<Response> _defaultResponseHandler(Request _) async => _defaultResponse;
+
+void main() {
+  group('http-utils', () {
+    group('delegating-client', () {
+      test('not-close-underlying-client', () {
+        final mock = mockClient(_defaultResponseHandler, expectClose: false);
+        DelegatingClientImpl(mock, closeUnderlyingClient: false).close();
+      });
+
+      test('close-underlying-client', () {
+        final mock = mockClient(_defaultResponseHandler);
+        DelegatingClientImpl(mock, closeUnderlyingClient: true).close();
+      });
+
+      test('close-several-times', () {
+        final mock = mockClient(_defaultResponseHandler);
+        final delegate =
+            DelegatingClientImpl(mock, closeUnderlyingClient: true);
+        delegate.close();
+        expect(delegate.close, throwsA(isStateError));
+      });
+    });
+
+    group('refcounted-client', () {
+      test('not-close-underlying-client', () {
+        final mock = mockClient(_defaultResponseHandler, expectClose: false);
+        final client = RefCountedClient(mock, initialRefCount: 3);
+        client.close();
+        client.close();
+      });
+
+      test('close-underlying-client', () {
+        final mock = mockClient(_defaultResponseHandler);
+        final client = RefCountedClient(mock, initialRefCount: 3);
+        client.close();
+        client.close();
+        client.close();
+      });
+
+      test('acquire-release', () {
+        final mock = mockClient(_defaultResponseHandler);
+        final client = RefCountedClient(mock);
+        client.acquire();
+        client.release();
+        client.acquire();
+        client.release();
+        client.release();
+      });
+
+      test('close-several-times', () {
+        final mock = mockClient(_defaultResponseHandler);
+        final client = RefCountedClient(mock);
+        client.close();
+        expect(client.close, throwsA(isStateError));
+      });
+    });
+
+    group('api-client', () {
+      const key = 'foo%?bar';
+      final keyEncoded = 'key=${Uri.encodeQueryComponent(key)}';
+
+      RequestImpl request(String url) => RequestImpl('GET', Uri.parse(url));
+      Future<Response> responseF() =>
+          Future<Response>.value(Response.bytes([], 200));
+
+      test('no-query-string', () {
+        final mock = mockClient((Request request) {
+          expect('${request.url}', equals('http://localhost/abc?$keyEncoded'));
+          return responseF();
+        });
+
+        final client = ApiKeyClient(mock, key);
+        expect(client.send(request('http://localhost/abc')), completes);
+        client.close();
+      });
+
+      test('with-query-string', () {
+        final mock = mockClient((Request request) {
+          expect(
+              '${request.url}', equals('http://localhost/abc?x&$keyEncoded'));
+          return responseF();
+        });
+
+        final client = ApiKeyClient(mock, key);
+        expect(client.send(request('http://localhost/abc?x')), completes);
+        client.close();
+      });
+
+      test('with-existing-key', () {
+        final mock =
+            mockClient(expectAsync1(_defaultResponseHandler, count: 0));
+
+        final client = ApiKeyClient(mock, key);
+        expect(client.send(request('http://localhost/abc?key=a')),
+            throwsException);
+        client.close();
+      });
+    });
+
+    test('non-closing-client', () {
+      final mock = mockClient(_defaultResponseHandler, expectClose: false);
+      nonClosingClient(mock).close();
+    });
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/auth_code_test.dart b/googleapis_auth/test/oauth2_flows/auth_code_test.dart
new file mode 100644
index 0000000..f223dd0
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/auth_code_test.dart
@@ -0,0 +1,260 @@
+// 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.
+
+library googleapis_auth.auth_code_test;
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:googleapis_auth/auth.dart';
+import 'package:googleapis_auth/src/oauth2_flows/auth_code.dart';
+import 'package:http/http.dart';
+import 'package:test/test.dart';
+
+import '../test_utils.dart';
+
+typedef RequestHandler = Future<Response> Function(Request _);
+
+void main() {
+  final clientId = ClientId('id', 'secret');
+  final scopes = ['s1', 's2'];
+
+  // Validation + Responses from the authorization server.
+
+  RequestHandler successFullResponse({bool? manual}) =>
+      (Request request) async {
+        expect(request.method, equals('POST'));
+        expect('${request.url}',
+            equals('https://accounts.google.com/o/oauth2/token'));
+        expect(
+            request.headers['content-type']!
+                .startsWith('application/x-www-form-urlencoded'),
+            isTrue);
+
+        final pairs = request.body.split('&');
+        expect(pairs, hasLength(5));
+        expect(pairs[0], equals('grant_type=authorization_code'));
+        expect(pairs[1], equals('code=mycode'));
+        expect(pairs[3], equals('client_id=id'));
+        expect(pairs[4], equals('client_secret=secret'));
+        if (manual!) {
+          expect(pairs[2],
+              equals('redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob'));
+        } else {
+          expect(pairs[2], startsWith('redirect_uri='));
+
+          final url = Uri.parse(
+              Uri.decodeComponent(pairs[2].substring('redirect_uri='.length)));
+          expect(url.scheme, equals('http'));
+          expect(url.host, equals('localhost'));
+        }
+
+        final result = {
+          'token_type': 'Bearer',
+          'access_token': 'tokendata',
+          'expires_in': 3600,
+          'refresh_token': 'my-refresh-token',
+          'id_token': 'my-id-token',
+        };
+        return Response(jsonEncode(result), 200);
+      };
+
+  Future<Response> invalidResponse(Request request) {
+    // Missing expires_in field!
+    final result = {
+      'token_type': 'Bearer',
+      'access_token': 'tokendata',
+      'refresh_token': 'my-refresh-token',
+      'id_token': 'my-id-token',
+    };
+    return Future.value(Response(jsonEncode(result), 200));
+  }
+
+  // Validation functions for user prompt and access credentials.
+
+  void validateAccessCredentials(AccessCredentials credentials) {
+    expect(credentials.accessToken.data, equals('tokendata'));
+    expect(credentials.accessToken.type, equals('Bearer'));
+    expect(credentials.scopes, equals(['s1', 's2']));
+    expect(credentials.refreshToken, equals('my-refresh-token'));
+    expect(credentials.idToken, equals('my-id-token'));
+    expectExpiryOneHourFromNow(credentials.accessToken);
+  }
+
+  Uri validateUserPromptUri(String url, {bool manual = false}) {
+    final uri = Uri.parse(url);
+    expect(uri.scheme, equals('https'));
+    expect(uri.host, equals('accounts.google.com'));
+    expect(uri.path, equals('/o/oauth2/auth'));
+    expect(uri.queryParameters['client_id'], equals(clientId.identifier));
+    expect(uri.queryParameters['response_type'], equals('code'));
+    expect(uri.queryParameters['scope'], equals('s1 s2'));
+    expect(uri.queryParameters['redirect_uri'], isNotNull);
+
+    final redirectUri = Uri.parse(uri.queryParameters['redirect_uri']!);
+
+    if (manual) {
+      expect('$redirectUri', equals('urn:ietf:wg:oauth:2.0:oob'));
+    } else {
+      expect(uri.queryParameters['state'], isNotNull);
+      expect(redirectUri.scheme, equals('http'));
+      expect(redirectUri.host, equals('localhost'));
+    }
+
+    return redirectUri;
+  }
+
+  group('authorization-code-flow', () {
+    group('manual-copy-paste', () {
+      Future<String> manualUserPrompt(String url) {
+        validateUserPromptUri(url, manual: true);
+        return Future.value('mycode');
+      }
+
+      test('successfull', () async {
+        final flow = AuthorizationCodeGrantManualFlow(
+            clientId,
+            scopes,
+            mockClient(successFullResponse(manual: true), expectClose: false),
+            manualUserPrompt);
+        validateAccessCredentials(await flow.run());
+      });
+
+      test('user-exception', () {
+        // We use a TransportException here for convenience.
+        Future<String> manualUserPromptError(String url) =>
+            Future.error(TransportException());
+
+        final flow = AuthorizationCodeGrantManualFlow(
+            clientId,
+            scopes,
+            mockClient(successFullResponse(manual: true), expectClose: false),
+            manualUserPromptError);
+        expect(flow.run(), throwsA(isTransportException));
+      });
+
+      test('transport-exception', () {
+        final flow = AuthorizationCodeGrantManualFlow(
+            clientId, scopes, transportFailure, manualUserPrompt);
+        expect(flow.run(), throwsA(isTransportException));
+      });
+
+      test('invalid-server-response', () {
+        final flow = AuthorizationCodeGrantManualFlow(clientId, scopes,
+            mockClient(invalidResponse, expectClose: false), manualUserPrompt);
+        expect(flow.run(), throwsA(isException));
+      });
+    });
+
+    group('http-server', () {
+      void callRedirectionEndpoint(Uri authCodeCall) {
+        final ioClient = HttpClient();
+        ioClient
+            .getUrl(authCodeCall)
+            .then((request) => request.close())
+            .then((response) => response.drain())
+            .whenComplete(expectAsync0(ioClient.close));
+      }
+
+      void userPrompt(String url) {
+        final redirectUri = validateUserPromptUri(url);
+        final authCodeCall = Uri(
+            scheme: redirectUri.scheme,
+            host: redirectUri.host,
+            port: redirectUri.port,
+            path: redirectUri.path,
+            queryParameters: {
+              'state': Uri.parse(url).queryParameters['state'],
+              'code': 'mycode',
+            });
+        callRedirectionEndpoint(authCodeCall);
+      }
+
+      void userPromptInvalidAuthCodeCallback(String url) {
+        final redirectUri = validateUserPromptUri(url);
+        final authCodeCall = Uri(
+            scheme: redirectUri.scheme,
+            host: redirectUri.host,
+            port: redirectUri.port,
+            path: redirectUri.path,
+            queryParameters: {
+              'state': Uri.parse(url).queryParameters['state'],
+              'error': 'failed to authenticate',
+            });
+        callRedirectionEndpoint(authCodeCall);
+      }
+
+      test('successfull', () async {
+        final flow = AuthorizationCodeGrantServerFlow(
+            clientId,
+            scopes,
+            mockClient(successFullResponse(manual: false), expectClose: false),
+            expectAsync1(userPrompt));
+        validateAccessCredentials(await flow.run());
+      });
+
+      test('transport-exception', () {
+        final flow = AuthorizationCodeGrantServerFlow(
+            clientId, scopes, transportFailure, expectAsync1(userPrompt));
+        expect(flow.run(), throwsA(isTransportException));
+      });
+
+      test('invalid-server-response', () {
+        final flow = AuthorizationCodeGrantServerFlow(
+            clientId,
+            scopes,
+            mockClient(invalidResponse, expectClose: false),
+            expectAsync1(userPrompt));
+        expect(flow.run(), throwsA(isException));
+      });
+
+      test('failed-authentication', () {
+        final flow = AuthorizationCodeGrantServerFlow(
+            clientId,
+            scopes,
+            mockClient(successFullResponse(manual: false), expectClose: false),
+            expectAsync1(userPromptInvalidAuthCodeCallback));
+        expect(flow.run(), throwsA(isUserConsentException));
+      });
+    }, testOn: '!browser');
+  });
+
+  group('scopes-from-tokeninfo-endpoint', () {
+    final successfulResponseJson = jsonEncode({
+      'issued_to': 'XYZ.apps.googleusercontent.com',
+      'audience': 'XYZ.apps.googleusercontent.com',
+      'scope': 'scopeA scopeB',
+      'expires_in': 3210,
+      'access_type': 'offline'
+    });
+    const expectedUri =
+        'https://www.googleapis.com/oauth2/v2/tokeninfo?access_token=my_token';
+
+    test('successfull', () async {
+      final http = mockClient(expectAsync1((BaseRequest request) async {
+        expect(request.url.toString(), expectedUri);
+        return Response(successfulResponseJson, 200);
+      }), expectClose: false);
+      final scopes = await obtainScopesFromAccessToken('my_token', http);
+      expect(scopes, equals(['scopeA', 'scopeB']));
+    });
+
+    test('non-200-status-code', () {
+      final http = mockClient(expectAsync1((BaseRequest request) async {
+        expect(request.url.toString(), expectedUri);
+        return Response(successfulResponseJson, 201);
+      }), expectClose: false);
+      expect(obtainScopesFromAccessToken('my_token', http), throwsException);
+    });
+
+    test('no-scope', () {
+      final http = mockClient(expectAsync1((BaseRequest request) async {
+        expect(request.url.toString(), expectedUri);
+        return Response(jsonEncode({}), 200);
+      }), expectClose: false);
+      expect(obtainScopesFromAccessToken('my_token', http), throwsException);
+    });
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_force.js b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_force.js
new file mode 100644
index 0000000..16e58fa
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_force.js
@@ -0,0 +1,85 @@
+// 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.
+
+(function() {
+  // This function looks up the URL this script was loaded in and finds the
+  // name of the callback function to call when the library is read.
+  // The URL of the script load looks like:
+  //    http://localhost:8080/folder/file?onload=dartGapiLoaded
+  function findDartOnLoadCallback() {
+    var scripts = document.getElementsByTagName('script');
+    var self = scripts[scripts.length - 1];
+
+    var equalsSign = self.src.indexOf('=');
+    if (equalsSign <= 0) throw 'error';
+
+    var callbackName = self.src.substring(equalsSign + 1);
+    if (callbackName.length <= 0) throw 'error';
+
+    var dartFunction = window[callbackName];
+    if (dartFunction == null) throw 'error';
+
+    return dartFunction;
+  }
+
+  function GapiAuth() {}
+  GapiAuth.prototype.init = function(doneCallback) {
+    doneCallback();
+  };
+  GapiAuth.prototype.authorize = function(json, doneCallback) {
+    /*
+      Input:
+        argument1 = {
+            'client_id'
+            'immediate'
+            'approval_prompt'
+            'response_type'
+            'scope'
+            'access_type'
+        };
+        argument2 = dartCallback(json);
+
+      Output:
+        output_1 = {
+          'token_type',
+          'access_token',
+          'expires_in',
+          'code',
+          'state',
+          'error',
+        };
+    */
+
+    var client_id = json['client_id'];
+    var immediate = json['immediate'];
+    var approval_prompt = json['approval_prompt'];
+    var response_type = json['response_type'];
+    var scope = json['scope'];
+    var access_type = json['access_type'];
+
+    if (client_id == 'foo_client' &&
+        immediate == false &&
+        approval_prompt == 'force' &&
+        response_type == 'code token' &&
+        scope == 'scope1 scope2' &&
+        access_type == 'offline') {
+      doneCallback({
+        'token_type' : 'Bearer',
+        'access_token' : 'foo_token',
+        'expires_in' : '3210',
+        'code' : 'mycode'
+      });
+    } else {
+      throw 'error';
+    }
+  };
+
+  // Initialize the gapi.auth mock.
+  window.gapi = new Object();
+  window.gapi.auth = new GapiAuth();
+
+  // Call the dart function. This signals that gapi.auth was loaded.
+  var dartFunction = findDartOnLoadCallback();
+  dartFunction();
+})();
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_force_test.dart b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_force_test.dart
new file mode 100644
index 0000000..8633efb
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_force_test.dart
@@ -0,0 +1,39 @@
+// 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.
+
+@TestOn('browser')
+import 'package:test/test.dart';
+import 'package:googleapis_auth/auth_browser.dart' as auth;
+import 'package:googleapis_auth/src/oauth2_flows/implicit.dart' as impl;
+import 'package:googleapis_auth/src/utils.dart' as utils;
+
+import 'utils.dart';
+
+void main() {
+  impl.gapiUrl = resource('gapi_auth_hybrid_force.js');
+
+  test('gapi-auth-hybrid-force-test', () async {
+    final clientId = auth.ClientId('foo_client', 'foo_secret');
+    final scopes = ['scope1', 'scope2'];
+
+    final flow = await auth.createImplicitBrowserFlow(clientId, scopes);
+    final result = await flow.runHybridFlow();
+
+    final credentials = result.credentials;
+
+    final date = DateTime.now().toUtc().add(
+        const Duration(seconds: 3210 - utils.maxExpectedTimeDiffInSeconds));
+    final difference = credentials.accessToken.expiry.difference(date);
+    final seconds = difference.inSeconds;
+
+    expect(-3 <= seconds && seconds <= 3, isTrue);
+    expect(credentials.accessToken.data, 'foo_token');
+    expect(credentials.refreshToken, isNull);
+    expect(credentials.scopes, hasLength(2));
+    expect(credentials.scopes[0], 'scope1');
+    expect(credentials.scopes[1], 'scope2');
+
+    expect(result.authorizationCode, 'mycode');
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_immediate.js b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_immediate.js
new file mode 100644
index 0000000..57da7e2
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_immediate.js
@@ -0,0 +1,85 @@
+// 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.
+
+(function() {
+  // This function looks up the URL this script was loaded in and finds the
+  // name of the callback function to call when the library is read.
+  // The URL of the script load looks like:
+  //    http://localhost:8080/folder/file?onload=dartGapiLoaded
+  function findDartOnLoadCallback() {
+    var scripts = document.getElementsByTagName('script');
+    var self = scripts[scripts.length - 1];
+
+    var equalsSign = self.src.indexOf('=');
+    if (equalsSign <= 0) throw 'error';
+
+    var callbackName = self.src.substring(equalsSign + 1);
+    if (callbackName.length <= 0) throw 'error';
+
+    var dartFunction = window[callbackName];
+    if (dartFunction == null) throw 'error';
+
+    return dartFunction;
+  }
+
+  function GapiAuth() {}
+  GapiAuth.prototype.init = function(doneCallback) {
+    doneCallback();
+  };
+  GapiAuth.prototype.authorize = function(json, doneCallback) {
+    /*
+      Input:
+        argument1 = {
+            'client_id'
+            'immediate'
+            'approval_prompt'
+            'response_type'
+            'scope'
+            'access_type'
+        };
+        argument2 = dartCallback(json);
+
+      Output:
+        output_1 = {
+          'token_type',
+          'access_token',
+          'expires_in',
+          'code',
+          'state',
+          'error',
+        };
+    */
+
+    var client_id = json['client_id'];
+    var immediate = json['immediate'];
+    var approval_prompt = json['approval_prompt'];
+    var response_type = json['response_type'];
+    var scope = json['scope'];
+    var access_type = json['access_type'];
+
+    if (client_id == 'foo_client' &&
+        immediate == true &&
+        approval_prompt == 'auto' &&
+        response_type == 'code token' &&
+        scope == 'scope1 scope2' &&
+        access_type == 'offline') {
+      doneCallback({
+        'token_type' : 'Bearer',
+        'access_token' : 'foo_token',
+        'expires_in' : '3210',
+        'code' : 'mycode'
+      });
+    } else {
+      throw 'error';
+    }
+  };
+
+  // Initialize the gapi.auth mock.
+  window.gapi = new Object();
+  window.gapi.auth = new GapiAuth();
+
+  // Call the dart function. This signals that gapi.auth was loaded.
+  var dartFunction = findDartOnLoadCallback();
+  dartFunction();
+})();
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_immediate_test.dart b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_immediate_test.dart
new file mode 100644
index 0000000..e30a79c
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_immediate_test.dart
@@ -0,0 +1,38 @@
+// 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.
+
+@TestOn('browser')
+import 'package:test/test.dart';
+import 'package:googleapis_auth/auth_browser.dart' as auth;
+import 'package:googleapis_auth/src/oauth2_flows/implicit.dart' as impl;
+import 'package:googleapis_auth/src/utils.dart' as utils;
+
+import 'utils.dart';
+
+void main() {
+  impl.gapiUrl = resource('gapi_auth_hybrid_immediate.js');
+
+  test('gapi-auth-hybrid-immediate-test', () async {
+    final clientId = auth.ClientId('foo_client', 'foo_secret');
+    final scopes = ['scope1', 'scope2'];
+
+    final flow = await auth.createImplicitBrowserFlow(clientId, scopes);
+    final result = await flow.runHybridFlow(force: false, immediate: true);
+    final credentials = result.credentials;
+
+    final date = DateTime.now().toUtc().add(
+        const Duration(seconds: 3210 - utils.maxExpectedTimeDiffInSeconds));
+    final difference = credentials.accessToken.expiry.difference(date);
+    final seconds = difference.inSeconds;
+
+    expect(-3 <= seconds && seconds <= 3, isTrue);
+    expect(credentials.accessToken.data, 'foo_token');
+    expect(credentials.refreshToken, isNull);
+    expect(credentials.scopes, hasLength(2));
+    expect(credentials.scopes[0], 'scope1');
+    expect(credentials.scopes[1], 'scope2');
+
+    expect(result.authorizationCode, 'mycode');
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_nonforce.js b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_nonforce.js
new file mode 100644
index 0000000..c459cea
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_nonforce.js
@@ -0,0 +1,85 @@
+// 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.
+
+(function() {
+  // This function looks up the URL this script was loaded in and finds the
+  // name of the callback function to call when the library is read.
+  // The URL of the script load looks like:
+  //    http://localhost:8080/folder/file?onload=dartGapiLoaded
+  function findDartOnLoadCallback() {
+    var scripts = document.getElementsByTagName('script');
+    var self = scripts[scripts.length - 1];
+
+    var equalsSign = self.src.indexOf('=');
+    if (equalsSign <= 0) throw 'error';
+
+    var callbackName = self.src.substring(equalsSign + 1);
+    if (callbackName.length <= 0) throw 'error';
+
+    var dartFunction = window[callbackName];
+    if (dartFunction == null) throw 'error';
+
+    return dartFunction;
+  }
+
+  function GapiAuth() {}
+  GapiAuth.prototype.init = function(doneCallback) {
+    doneCallback();
+  };
+  GapiAuth.prototype.authorize = function(json, doneCallback) {
+    /*
+      Input:
+        argument1 = {
+            'client_id'
+            'immediate'
+            'approval_prompt'
+            'response_type'
+            'scope'
+            'access_type'
+        };
+        argument2 = dartCallback(json);
+
+      Output:
+        output_1 = {
+          'token_type',
+          'access_token',
+          'expires_in',
+          'code',
+          'state',
+          'error',
+        };
+    */
+
+    var client_id = json['client_id'];
+    var immediate = json['immediate'];
+    var approval_prompt = json['approval_prompt'];
+    var response_type = json['response_type'];
+    var scope = json['scope'];
+    var access_type = json['access_type'];
+
+    if (client_id == 'foo_client' &&
+        immediate == false &&
+        approval_prompt == 'auto' &&
+        response_type == 'code token' &&
+        scope == 'scope1 scope2' &&
+        access_type == 'offline') {
+      doneCallback({
+        'token_type' : 'Bearer',
+        'access_token' : 'foo_token',
+        'expires_in' : '3210',
+        'code' : 'mycode'
+      });
+    } else {
+      throw 'error';
+    }
+  };
+
+  // Initialize the gapi.auth mock.
+  window.gapi = new Object();
+  window.gapi.auth = new GapiAuth();
+
+  // Call the dart function. This signals that gapi.auth was loaded.
+  var dartFunction = findDartOnLoadCallback();
+  dartFunction();
+})();
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_nonforce_test.dart b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_nonforce_test.dart
new file mode 100644
index 0000000..aa4fede
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_hybrid_nonforce_test.dart
@@ -0,0 +1,38 @@
+// 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.
+
+@TestOn('browser')
+import 'package:test/test.dart';
+import 'package:googleapis_auth/auth_browser.dart' as auth;
+import 'package:googleapis_auth/src/oauth2_flows/implicit.dart' as impl;
+import 'package:googleapis_auth/src/utils.dart' as utils;
+
+import 'utils.dart';
+
+void main() {
+  impl.gapiUrl = resource('gapi_auth_hybrid_nonforce.js');
+
+  test('gapi-auth-hybrid-nonforce-test', () async {
+    final clientId = auth.ClientId('foo_client', 'foo_secret');
+    final scopes = ['scope1', 'scope2'];
+
+    final flow = await auth.createImplicitBrowserFlow(clientId, scopes);
+    final result = await flow.runHybridFlow(force: false);
+    final credentials = result.credentials;
+
+    final date = DateTime.now().toUtc().add(
+        const Duration(seconds: 3210 - utils.maxExpectedTimeDiffInSeconds));
+    final difference = credentials.accessToken.expiry.difference(date);
+    final seconds = difference.inSeconds;
+
+    expect(-3 <= seconds && seconds <= 3, isTrue);
+    expect(credentials.accessToken.data, 'foo_token');
+    expect(credentials.refreshToken, isNull);
+    expect(credentials.scopes, hasLength(2));
+    expect(credentials.scopes[0], 'scope1');
+    expect(credentials.scopes[1], 'scope2');
+
+    expect(result.authorizationCode, 'mycode');
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_immediate.js b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_immediate.js
new file mode 100644
index 0000000..24e942d
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_immediate.js
@@ -0,0 +1,84 @@
+// 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.
+
+(function() {
+  // This function looks up the URL this script was loaded in and finds the
+  // name of the callback function to call when the library is read.
+  // The URL of the script load looks like:
+  //    http://localhost:8080/folder/file?onload=dartGapiLoaded
+  function findDartOnLoadCallback() {
+    var scripts = document.getElementsByTagName('script');
+    var self = scripts[scripts.length - 1];
+
+    var equalsSign = self.src.indexOf('=');
+    if (equalsSign <= 0) throw 'error';
+
+    var callbackName = self.src.substring(equalsSign + 1);
+    if (callbackName.length <= 0) throw 'error';
+
+    var dartFunction = window[callbackName];
+    if (dartFunction == null) throw 'error';
+
+    return dartFunction;
+  }
+
+  function GapiAuth() {}
+  GapiAuth.prototype.init = function(doneCallback) {
+    doneCallback();
+  };
+  GapiAuth.prototype.authorize = function(json, doneCallback) {
+    /*
+      Input:
+        argument1 = {
+            'client_id'
+            'immediate'
+            'approval_prompt'
+            'response_type'
+            'scope'
+            'access_type'
+        };
+        argument2 = dartCallback(json);
+
+      Output:
+        output_1 = {
+          'token_type',
+          'access_token',
+          'expires_in',
+          'code',
+          'state',
+          'error',
+        };
+    */
+
+    var client_id = json['client_id'];
+    var immediate = json['immediate'];
+    var approval_prompt = json['approval_prompt'];
+    var response_type = json['response_type'];
+    var scope = json['scope'];
+    var access_type = json['access_type'];
+
+    if (client_id == 'foo_client' &&
+        immediate == true &&
+        approval_prompt == 'auto' &&
+        response_type == 'token' &&
+        scope == 'scope1 scope2' &&
+        access_type == 'online') {
+      doneCallback({
+        'token_type' : 'Bearer',
+        'access_token' : 'foo_token',
+        'expires_in' : '3210'
+      });
+    } else {
+      throw 'error';
+    }
+  };
+
+  // Initialize the gapi.auth mock.
+  window.gapi = new Object();
+  window.gapi.auth = new GapiAuth();
+
+  // Call the dart function. This signals that gapi.auth was loaded.
+  var dartFunction = findDartOnLoadCallback();
+  dartFunction();
+})();
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_immediate_test.dart b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_immediate_test.dart
new file mode 100644
index 0000000..b8c9eb6
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_immediate_test.dart
@@ -0,0 +1,35 @@
+// 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.
+
+@TestOn('browser')
+import 'package:test/test.dart';
+import 'package:googleapis_auth/auth_browser.dart' as auth;
+import 'package:googleapis_auth/src/oauth2_flows/implicit.dart' as impl;
+import 'package:googleapis_auth/src/utils.dart' as utils;
+
+import 'utils.dart';
+
+void main() {
+  impl.gapiUrl = resource('gapi_auth_immediate.js');
+
+  test('gapi-auth-force', () async {
+    final clientId = auth.ClientId('foo_client', 'foo_secret');
+    final scopes = ['scope1', 'scope2'];
+
+    final flow = await auth.createImplicitBrowserFlow(clientId, scopes);
+    final credentials =
+        await flow.obtainAccessCredentialsViaUserConsent(immediate: true);
+    final date = DateTime.now().toUtc().add(
+        const Duration(seconds: 3210 - utils.maxExpectedTimeDiffInSeconds));
+    final difference = credentials.accessToken.expiry.difference(date);
+    final seconds = difference.inSeconds;
+
+    expect(-3 <= seconds && seconds <= 3, isTrue);
+    expect(credentials.accessToken.data, 'foo_token');
+    expect(credentials.refreshToken, isNull);
+    expect(credentials.scopes, hasLength(2));
+    expect(credentials.scopes[0], 'scope1');
+    expect(credentials.scopes[1], 'scope2');
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_implicit_idtoken.js b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_implicit_idtoken.js
new file mode 100644
index 0000000..cc01553
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_implicit_idtoken.js
@@ -0,0 +1,86 @@
+// 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.
+
+(function () {
+  // This function looks up the URL this script was loaded in and finds the
+  // name of the callback function to call when the library is read.
+  // The URL of the script load looks like:
+  //    http://localhost:8080/folder/file?onload=dartGapiLoaded
+  function findDartOnLoadCallback() {
+    var scripts = document.getElementsByTagName('script');
+    var self = scripts[scripts.length - 1];
+
+    var equalsSign = self.src.indexOf('=');
+    if (equalsSign <= 0) throw 'error';
+
+    var callbackName = self.src.substring(equalsSign + 1);
+    if (callbackName.length <= 0) throw 'error';
+
+    var dartFunction = window[callbackName];
+    if (dartFunction == null) throw 'error';
+
+    return dartFunction;
+  }
+
+  function GapiAuth() { }
+  GapiAuth.prototype.init = function (doneCallback) {
+    doneCallback();
+  };
+  GapiAuth.prototype.authorize = function (json, doneCallback) {
+    /*
+      Input:
+        argument1 = {
+            'client_id'
+            'immediate'
+            'approval_prompt'
+            'response_type'
+            'scope'
+            'access_type'
+        };
+        argument2 = dartCallback(json);
+
+      Output:
+        output_1 = {
+          'token_type',
+          'access_token',
+          'expires_in',
+          'id_token',
+          'code',
+          'state',
+          'error',
+        };
+    */
+
+    var client_id = json['client_id'];
+    var immediate = json['immediate'];
+    var approval_prompt = json['approval_prompt'];
+    var response_type = json['response_type'];
+    var scope = json['scope'];
+    var access_type = json['access_type'];
+
+    if (client_id == 'foo_client' &&
+      immediate == false &&
+      approval_prompt == 'auto' &&
+      response_type == 'id_token token' &&
+      scope == 'scope1 scope2' &&
+      access_type == 'online') {
+      doneCallback({
+        'token_type': 'Bearer',
+        'access_token': 'foo_token',
+        'id_token': 'foo_id_token',
+        'expires_in': '3210'
+      });
+    } else {
+      throw 'error';
+    }
+  };
+
+  // Initialize the gapi.auth mock.
+  window.gapi = new Object();
+  window.gapi.auth = new GapiAuth();
+
+  // Call the dart function. This signals that gapi.auth was loaded.
+  var dartFunction = findDartOnLoadCallback();
+  dartFunction();
+})();
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_implicit_idtoken_test.dart b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_implicit_idtoken_test.dart
new file mode 100644
index 0000000..89f8941
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_implicit_idtoken_test.dart
@@ -0,0 +1,37 @@
+// 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.
+
+@TestOn('browser')
+import 'package:test/test.dart';
+import 'package:googleapis_auth/auth_browser.dart' as auth;
+import 'package:googleapis_auth/src/oauth2_flows/implicit.dart' as impl;
+import 'package:googleapis_auth/src/utils.dart' as utils;
+
+import 'utils.dart';
+
+void main() {
+  impl.gapiUrl = resource('gapi_auth_implicit_idtoken.js');
+
+  test('gapi-auth-implicit-idtoken', () async {
+    final clientId = auth.ClientId('foo_client', 'foo_secret');
+    final scopes = ['scope1', 'scope2'];
+
+    final flow = await auth.createImplicitBrowserFlow(clientId, scopes);
+    final credentials = await flow.obtainAccessCredentialsViaUserConsent(
+        responseTypes: [auth.ResponseType.idToken, auth.ResponseType.token]);
+
+    final date = DateTime.now().toUtc().add(
+        const Duration(seconds: 3210 - utils.maxExpectedTimeDiffInSeconds));
+    final difference = credentials.accessToken.expiry.difference(date);
+    final seconds = difference.inSeconds;
+
+    expect(-3 <= seconds && seconds <= 3, isTrue);
+    expect(credentials.accessToken.data, 'foo_token');
+    expect(credentials.refreshToken, isNull);
+    expect(credentials.scopes, hasLength(2));
+    expect(credentials.scopes[0], 'scope1');
+    expect(credentials.scopes[1], 'scope2');
+    expect(credentials.idToken, 'foo_id_token');
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_nonforce.js b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_nonforce.js
new file mode 100644
index 0000000..c071126
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_nonforce.js
@@ -0,0 +1,84 @@
+// 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.
+
+(function() {
+  // This function looks up the URL this script was loaded in and finds the
+  // name of the callback function to call when the library is read.
+  // The URL of the script load looks like:
+  //    http://localhost:8080/folder/file?onload=dartGapiLoaded
+  function findDartOnLoadCallback() {
+    var scripts = document.getElementsByTagName('script');
+    var self = scripts[scripts.length - 1];
+
+    var equalsSign = self.src.indexOf('=');
+    if (equalsSign <= 0) throw 'error';
+
+    var callbackName = self.src.substring(equalsSign + 1);
+    if (callbackName.length <= 0) throw 'error';
+
+    var dartFunction = window[callbackName];
+    if (dartFunction == null) throw 'error';
+
+    return dartFunction;
+  }
+
+  function GapiAuth() {}
+  GapiAuth.prototype.init = function(doneCallback) {
+    doneCallback();
+  };
+  GapiAuth.prototype.authorize = function(json, doneCallback) {
+    /*
+      Input:
+        argument1 = {
+            'client_id'
+            'immediate'
+            'approval_prompt'
+            'response_type'
+            'scope'
+            'access_type'
+        };
+        argument2 = dartCallback(json);
+
+      Output:
+        output_1 = {
+          'token_type',
+          'access_token',
+          'expires_in',
+          'code',
+          'state',
+          'error',
+        };
+    */
+
+    var client_id = json['client_id'];
+    var immediate = json['immediate'];
+    var approval_prompt = json['approval_prompt'];
+    var response_type = json['response_type'];
+    var scope = json['scope'];
+    var access_type = json['access_type'];
+
+    if (client_id == 'foo_client' &&
+        immediate == false &&
+        approval_prompt == 'auto' &&
+        response_type == 'token' &&
+        scope == 'scope1 scope2' &&
+        access_type == 'online') {
+      doneCallback({
+        'token_type' : 'Bearer',
+        'access_token' : 'foo_token',
+        'expires_in' : '3210'
+      });
+    } else {
+      throw 'error';
+    }
+  };
+
+  // Initialize the gapi.auth mock.
+  window.gapi = new Object();
+  window.gapi.auth = new GapiAuth();
+
+  // Call the dart function. This signals that gapi.auth was loaded.
+  var dartFunction = findDartOnLoadCallback();
+  dartFunction();
+})();
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_nonforce_test.dart b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_nonforce_test.dart
new file mode 100644
index 0000000..7e42428
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_nonforce_test.dart
@@ -0,0 +1,35 @@
+// 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.
+
+@TestOn('browser')
+import 'package:test/test.dart';
+import 'package:googleapis_auth/auth_browser.dart' as auth;
+import 'package:googleapis_auth/src/oauth2_flows/implicit.dart' as impl;
+import 'package:googleapis_auth/src/utils.dart' as utils;
+
+import 'utils.dart';
+
+void main() {
+  impl.gapiUrl = resource('gapi_auth_nonforce.js');
+
+  test('gapi-auth-nonforce', () async {
+    final clientId = auth.ClientId('foo_client', 'foo_secret');
+    final scopes = ['scope1', 'scope2'];
+
+    final flow = await auth.createImplicitBrowserFlow(clientId, scopes);
+    final credentials = await flow.obtainAccessCredentialsViaUserConsent();
+
+    final date = DateTime.now().toUtc().add(
+        const Duration(seconds: 3210 - utils.maxExpectedTimeDiffInSeconds));
+    final difference = credentials.accessToken.expiry.difference(date);
+    final seconds = difference.inSeconds;
+
+    expect(-3 <= seconds && seconds <= 3, isTrue);
+    expect(credentials.accessToken.data, 'foo_token');
+    expect(credentials.refreshToken, isNull);
+    expect(credentials.scopes, hasLength(2));
+    expect(credentials.scopes[0], 'scope1');
+    expect(credentials.scopes[1], 'scope2');
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_user_denied.js b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_user_denied.js
new file mode 100644
index 0000000..4c27dc4
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_user_denied.js
@@ -0,0 +1,82 @@
+// 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.
+
+(function() {
+  // This function looks up the URL this script was loaded in and finds the
+  // name of the callback function to call when the library is read.
+  // The URL of the script load looks like:
+  //    http://localhost:8080/folder/file?onload=dartGapiLoaded
+  function findDartOnLoadCallback() {
+    var scripts = document.getElementsByTagName('script');
+    var self = scripts[scripts.length - 1];
+
+    var equalsSign = self.src.indexOf('=');
+    if (equalsSign <= 0) throw 'error';
+
+    var callbackName = self.src.substring(equalsSign + 1);
+    if (callbackName.length <= 0) throw 'error';
+
+    var dartFunction = window[callbackName];
+    if (dartFunction == null) throw 'error';
+
+    return dartFunction;
+  }
+
+  function GapiAuth() {}
+  GapiAuth.prototype.init = function(doneCallback) {
+    doneCallback();
+  };
+  GapiAuth.prototype.authorize = function(json, doneCallback) {
+    /*
+      Input:
+        argument1 = {
+            'client_id'
+            'immediate'
+            'approval_prompt'
+            'response_type'
+            'scope'
+            'access_type'
+        };
+        argument2 = dartCallback(json);
+
+      Output:
+        output_1 = {
+          'token_type',
+          'access_token',
+          'expires_in',
+          'code',
+          'state',
+          'error',
+        };
+    */
+
+    var client_id = json['client_id'];
+    var immediate = json['immediate'];
+    var approval_prompt = json['approval_prompt'];
+    var response_type = json['response_type'];
+    var scope = json['scope'];
+    var access_type = json['access_type'];
+
+    if (client_id == 'foo_client' &&
+        immediate == false &&
+        approval_prompt == 'auto' &&
+        response_type == 'token' &&
+        scope == 'scope1 scope2' &&
+        access_type == 'online') {
+      doneCallback({
+        'error' : 'failed to get user consent',
+      });
+    } else {
+      throw 'error';
+    }
+  };
+
+  // Initialize the gapi.auth mock.
+  window.gapi = new Object();
+  window.gapi.auth = new GapiAuth();
+
+  // Call the dart function. This signals that gapi.auth was loaded.
+  var dartFunction = findDartOnLoadCallback();
+  dartFunction();
+})();
\ No newline at end of file
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_user_denied_test.dart b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_user_denied_test.dart
new file mode 100644
index 0000000..cf8e45a
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_auth_user_denied_test.dart
@@ -0,0 +1,27 @@
+// 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.
+
+@TestOn('browser')
+import 'package:test/test.dart';
+import 'package:googleapis_auth/auth_browser.dart' as auth;
+import 'package:googleapis_auth/src/oauth2_flows/implicit.dart' as impl;
+
+import 'utils.dart';
+
+void main() {
+  impl.gapiUrl = resource('gapi_auth_user_denied.js');
+
+  test('gapi-auth-user-denied', () async {
+    final clientId = auth.ClientId('foo_client', 'foo_secret');
+    final scopes = ['scope1', 'scope2'];
+
+    final flow = await auth.createImplicitBrowserFlow(clientId, scopes);
+    try {
+      await flow.obtainAccessCredentialsViaUserConsent();
+      fail('expected error');
+    } catch (error) {
+      expect(error is auth.UserConsentException, isTrue);
+    }
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_failure.js b/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_failure.js
new file mode 100644
index 0000000..be1f56c
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_failure.js
@@ -0,0 +1,39 @@
+// 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.
+
+(function() {
+  // This function looks up the URL this script was loaded in and finds the
+  // name of the callback function to call when the library is read.
+  // The URL of the script load looks like:
+  //    http://localhost:8080/folder/file?onload=dartGapiLoaded
+  function findDartOnLoadCallback() {
+    var scripts = document.getElementsByTagName('script');
+    var self = scripts[scripts.length - 1];
+
+    var equalsSign = self.src.indexOf('=');
+    if (equalsSign <= 0) throw 'error';
+
+    var callbackName = self.src.substring(equalsSign + 1);
+    if (callbackName.length <= 0) throw 'error';
+
+    var dartFunction = window[callbackName];
+    if (dartFunction == null) throw 'error';
+
+    return dartFunction;
+  }
+
+  function GapiAuth() {}
+  // We do not set the init/authorize functions, which should make the
+  // the initialization fail.
+  //    GapiAuth.prototype.init = ...;
+  //    GapiAuth.prototype.authorize = ...;
+
+  // Initialize the gapi.auth mock.
+  window.gapi = new Object();
+  window.gapi.auth = new GapiAuth();
+
+  // Call the dart function. This signals that gapi.auth was loaded.
+  var dartFunction = findDartOnLoadCallback();
+  dartFunction();
+})();
\ No newline at end of file
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_failure_test.dart b/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_failure_test.dart
new file mode 100644
index 0000000..2dc43dd
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_failure_test.dart
@@ -0,0 +1,21 @@
+// 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.
+
+@TestOn('browser')
+import 'package:test/test.dart';
+import 'package:googleapis_auth/auth_browser.dart' as auth;
+import 'package:googleapis_auth/src/oauth2_flows/implicit.dart' as impl;
+
+import 'utils.dart';
+
+void main() {
+  impl.gapiUrl = resource('gapi_initialize_failure.js');
+
+  test('gapi-initialize-failure', () {
+    final clientId = auth.ClientId('a', 'b');
+    final scopes = ['scope1', 'scope2'];
+
+    expect(auth.createImplicitBrowserFlow(clientId, scopes), throwsStateError);
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_successful.js b/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_successful.js
new file mode 100644
index 0000000..308db5b
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_successful.js
@@ -0,0 +1,37 @@
+// 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.
+
+(function() {
+  // This function looks up the URL this script was loaded in and finds the
+  // name of the callback function to call when the library is read.
+  // The URL of the script load looks like:
+  //    http://localhost:8080/folder/file?onload=dartGapiLoaded
+  function findDartOnLoadCallback() {
+    var scripts = document.getElementsByTagName('script');
+    var self = scripts[scripts.length - 1];
+
+    var equalsSign = self.src.indexOf('=');
+    if (equalsSign <= 0) throw 'error';
+
+    var callbackName = self.src.substring(equalsSign + 1);
+    if (callbackName.length <= 0) throw 'error';
+
+    var dartFunction = window[callbackName];
+    if (dartFunction == null) throw 'error';
+
+    return dartFunction;
+  }
+
+  // Initialize the gapi.auth mock.
+  function GapiAuth() {}
+  GapiAuth.prototype.init = function (dartCallback) {
+    dartCallback();
+  };
+  window.gapi = new Object();
+  window.gapi.auth = new GapiAuth();
+
+  // Call the dart function. This signals that gapi.auth was loaded.
+  var dartFunction = findDartOnLoadCallback();
+  dartFunction();
+})();
\ No newline at end of file
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_successful_test.dart b/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_successful_test.dart
new file mode 100644
index 0000000..b2d8522
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_initialize_successful_test.dart
@@ -0,0 +1,23 @@
+// 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.
+
+@TestOn('browser')
+import 'package:test/test.dart';
+import 'package:googleapis_auth/auth_browser.dart' as auth;
+import 'package:googleapis_auth/src/oauth2_flows/implicit.dart' as impl;
+
+import 'utils.dart';
+
+void main() {
+  impl.gapiUrl = resource('gapi_initialize_successful.js');
+
+  test('gapi-initialize-successful', () {
+    final clientId = auth.ClientId('a', 'b');
+    final clientId2 = auth.ClientId('c', 'd');
+    final scopes = ['scope1', 'scope2'];
+
+    expect(auth.createImplicitBrowserFlow(clientId, scopes), completes);
+    expect(auth.createImplicitBrowserFlow(clientId2, scopes), completes);
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_load_failure.js b/googleapis_auth/test/oauth2_flows/implicit/gapi_load_failure.js
new file mode 100644
index 0000000..d83bbba
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_load_failure.js
@@ -0,0 +1,6 @@
+// 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.
+
+// We do not set 'window.gapi = ...'
+this is a syntax error
\ No newline at end of file
diff --git a/googleapis_auth/test/oauth2_flows/implicit/gapi_load_failure_test.dart b/googleapis_auth/test/oauth2_flows/implicit/gapi_load_failure_test.dart
new file mode 100644
index 0000000..23e77c8
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/gapi_load_failure_test.dart
@@ -0,0 +1,50 @@
+// 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.
+
+@TestOn('browser')
+import 'dart:html';
+import 'dart:js' as js;
+
+import 'package:test/test.dart';
+import 'package:googleapis_auth/auth_browser.dart' as auth;
+import 'package:googleapis_auth/src/oauth2_flows/implicit.dart' as impl;
+
+import 'utils.dart';
+
+void main() {
+  // The default timeout is too small for us to detect the timeout of loading
+  // the gapi.auth library.
+  const timeout = Timeout(Duration(hours: 1));
+
+  final clientId = auth.ClientId('a', 'b');
+  final scopes = ['scope1', 'scope2'];
+
+  test('gapi-load-failure', () {
+    impl.gapiUrl = resource('non_existent.js');
+    expect(auth.createImplicitBrowserFlow(clientId, scopes), throwsException);
+  }, timeout: timeout);
+
+  test('gapi-load-failure--syntax-error', () async {
+    impl.gapiUrl = resource('gapi_load_failure.js');
+
+    // Reset test_controller.js's window.onerror registration.
+    // This makes sure we can catch the onError callback when the syntax error
+    // is produced.
+    js.context['onerror'] = null;
+
+    window.onError.listen(expectAsync1((error) {
+      error.preventDefault();
+    }));
+
+    final sw = Stopwatch()..start();
+    try {
+      await auth.createImplicitBrowserFlow(clientId, scopes);
+      fail('expected error');
+    } catch (error) {
+      final elapsed =
+          (sw.elapsed - impl.ImplicitFlow.callbackTimeout).inSeconds;
+      expect(-3 <= elapsed && elapsed <= 3, isTrue);
+    }
+  }, timeout: timeout);
+}
diff --git a/googleapis_auth/test/oauth2_flows/implicit/utils.dart b/googleapis_auth/test/oauth2_flows/implicit/utils.dart
new file mode 100644
index 0000000..a46dc71
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/implicit/utils.dart
@@ -0,0 +1,8 @@
+// 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:html';
+
+String resource(String name) =>
+    Uri.parse(document.baseUri!).resolve(name).toString();
diff --git a/googleapis_auth/test/oauth2_flows/jwt_test.dart b/googleapis_auth/test/oauth2_flows/jwt_test.dart
new file mode 100644
index 0000000..ae71de0
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/jwt_test.dart
@@ -0,0 +1,87 @@
+// 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.
+
+library googleapis_auth.jwt_test;
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:googleapis_auth/src/oauth2_flows/jwt.dart';
+import 'package:http/http.dart';
+import 'package:test/test.dart';
+
+import '../test_utils.dart';
+
+void main() {
+  const tokenUrl = 'https://accounts.google.com/o/oauth2/token';
+
+  Future<Response> successfulSignRequest(Request request) {
+    expect(request.method, equals('POST'));
+    expect(request.url.toString(), equals(tokenUrl));
+
+    // We are not asserting what comes after '&assertion=' because this is
+    // time dependent.
+    expect(
+        request.body,
+        startsWith(
+            'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer'
+            '&assertion='));
+    final body = jsonEncode({
+      'access_token': 'atok',
+      'expires_in': 3600,
+      'token_type': 'Bearer',
+    });
+    return Future.value(Response(body, 200));
+  }
+
+  Future<Response> invalidAccessToken(Request request) {
+    final body = jsonEncode({
+      // Missing 'expires_in' entry
+      'access_token': 'atok',
+      'token_type': 'Bearer',
+    });
+    return Future.value(Response(body, 200));
+  }
+
+  group('jwt-flow', () {
+    const clientEmail = 'a@b.com';
+    final scopes = ['s1', 's2'];
+
+    test('successfull', () async {
+      final flow = JwtFlow(clientEmail, testPrivateKey, null, scopes,
+          mockClient(expectAsync1(successfulSignRequest), expectClose: false));
+
+      final credentials = await flow.run();
+      expect(credentials.accessToken.data, equals('atok'));
+      expect(credentials.accessToken.type, equals('Bearer'));
+      expect(credentials.scopes, equals(['s1', 's2']));
+      expectExpiryOneHourFromNow(credentials.accessToken);
+    });
+
+    test('successfull-with-user', () async {
+      final flow = JwtFlow(clientEmail, testPrivateKey, 'x@y.com', scopes,
+          mockClient(expectAsync1(successfulSignRequest), expectClose: false));
+
+      final credentials = await flow.run();
+      expect(credentials.accessToken.data, equals('atok'));
+      expect(credentials.accessToken.type, equals('Bearer'));
+      expect(credentials.scopes, equals(['s1', 's2']));
+      expectExpiryOneHourFromNow(credentials.accessToken);
+    });
+
+    test('invalid-server-response', () {
+      final flow = JwtFlow(clientEmail, testPrivateKey, null, scopes,
+          mockClient(expectAsync1(invalidAccessToken), expectClose: false));
+
+      expect(flow.run(), throwsA(isException));
+    });
+
+    test('transport-failure', () {
+      final flow =
+          JwtFlow(clientEmail, testPrivateKey, null, scopes, transportFailure);
+
+      expect(flow.run(), throwsA(isTransportException));
+    });
+  });
+}
diff --git a/googleapis_auth/test/oauth2_flows/metadata_server_test.dart b/googleapis_auth/test/oauth2_flows/metadata_server_test.dart
new file mode 100644
index 0000000..302c4f6
--- /dev/null
+++ b/googleapis_auth/test/oauth2_flows/metadata_server_test.dart
@@ -0,0 +1,122 @@
+// 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.
+
+@TestOn('vm')
+library googleapis_auth.metadata_server;
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:googleapis_auth/src/oauth2_flows/metadata_server.dart';
+import 'package:http/http.dart';
+import 'package:test/test.dart';
+
+import '../test_utils.dart';
+
+void main() {
+  const apiUrl = 'http://metadata/computeMetadata/v1';
+  const apiHeaderKey = 'Metadata-Flavor';
+  const apiHeaderValue = 'Google';
+  const tokenUrl = '$apiUrl/instance/service-accounts/default/token';
+  const scopesUrl = '$apiUrl/instance/service-accounts/default/scopes';
+
+  Future<Response> successfulAccessToken(Request request) {
+    expect(request.method, equals('GET'));
+    expect(request.url.toString(), equals(tokenUrl));
+    expect(request.headers[apiHeaderKey], equals(apiHeaderValue));
+
+    final body = jsonEncode({
+      'access_token': 'atok',
+      'expires_in': 3600,
+      'token_type': 'Bearer',
+    });
+    return Future.value(Response(body, 200));
+  }
+
+  Future<Response> invalidAccessToken(Request request) {
+    expect(request.method, equals('GET'));
+    expect(request.url.toString(), equals(tokenUrl));
+    expect(request.headers[apiHeaderKey], equals(apiHeaderValue));
+
+    final body = jsonEncode({
+      // Missing 'expires_in' entry
+      'access_token': 'atok',
+      'token_type': 'Bearer',
+    });
+    return Future.value(Response(body, 200));
+  }
+
+  Future<Response> successfulScopes(Request request) {
+    expect(request.method, equals('GET'));
+    expect(request.url.toString(), equals(scopesUrl));
+    expect(request.headers[apiHeaderKey], equals(apiHeaderValue));
+
+    return Future.value(Response('s1\ns2', 200));
+  }
+
+  group('metadata-server-authorization-flow', () {
+    test('successfull', () async {
+      final flow = MetadataServerAuthorizationFlow(mockClient(
+          expectAsync1((request) {
+            final url = request.url.toString();
+            if (url == tokenUrl) {
+              return successfulAccessToken(request);
+            } else if (url == scopesUrl) {
+              return successfulScopes(request);
+            } else {
+              fail('Invalid URL $url (expected: $tokenUrl or $scopesUrl).');
+            }
+          }, count: 2),
+          expectClose: false));
+
+      final credentials = await flow.run();
+      expect(credentials.accessToken.data, equals('atok'));
+      expect(credentials.accessToken.type, equals('Bearer'));
+      expect(credentials.scopes, equals(['s1', 's2']));
+      expectExpiryOneHourFromNow(credentials.accessToken);
+    });
+
+    test('invalid-server-reponse', () {
+      var requestNr = 0;
+      final flow = MetadataServerAuthorizationFlow(mockClient(
+          expectAsync1((request) {
+            if (requestNr++ == 0) {
+              return invalidAccessToken(request);
+            } else {
+              return successfulScopes(request);
+            }
+          }, count: 2),
+          expectClose: false));
+      expect(flow.run(), throwsA(isException));
+    });
+
+    test('token-transport-error', () {
+      var requestNr = 0;
+      final flow = MetadataServerAuthorizationFlow(mockClient(
+          expectAsync1((request) {
+            if (requestNr++ == 0) {
+              return transportFailure.get(Uri.http('failure', ''));
+            } else {
+              return successfulScopes(request);
+            }
+          }, count: 2),
+          expectClose: false));
+      expect(flow.run(), throwsA(isTransportException));
+    });
+
+    test('scopes-transport-error', () {
+      var requestNr = 0;
+      final flow = MetadataServerAuthorizationFlow(mockClient(
+          expectAsync1((request) {
+            if (requestNr++ == 0) {
+              return successfulAccessToken(request);
+            } else {
+              return transportFailure.get(Uri.http('failure', ''));
+            }
+          }, count: 2),
+          expectClose: false));
+      expect(flow.run(), throwsA(isTransportException));
+    });
+  });
+}
diff --git a/googleapis_auth/test/oauth2_test.dart b/googleapis_auth/test/oauth2_test.dart
new file mode 100644
index 0000000..1584898
--- /dev/null
+++ b/googleapis_auth/test/oauth2_test.dart
@@ -0,0 +1,367 @@
+// 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.
+
+library googleapis_auth.oauth2_test;
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:googleapis_auth/auth.dart';
+import 'package:googleapis_auth/src/http_client_base.dart';
+import 'package:googleapis_auth/src/utils.dart';
+import 'package:http/http.dart';
+import 'package:test/test.dart';
+
+import 'test_utils.dart';
+
+final _defaultResponse = Response('', 500);
+
+Future<Response> _defaultResponseHandler(Request _) async => _defaultResponse;
+
+void main() {
+  test('access-token', () {
+    final expiry = DateTime.now().subtract(const Duration(seconds: 1));
+    final expiryUtc = expiry.toUtc();
+
+    expect(() => AccessToken('foo', 'bar', expiry), throwsArgumentError);
+
+    final token = AccessToken('foo', 'bar', expiryUtc);
+    expect(token.type, equals('foo'));
+    expect(token.data, equals('bar'));
+    expect(token.expiry, equals(expiryUtc));
+    expect(token.hasExpired, isTrue);
+
+    final nonExpiredToken =
+        AccessToken('foo', 'bar', expiryUtc.add(const Duration(days: 1)));
+    expect(nonExpiredToken.hasExpired, isFalse);
+  });
+
+  test('access-credentials', () {
+    final expiry = DateTime.now().add(const Duration(days: 1)).toUtc();
+    final aToken = AccessToken('foo', 'bar', expiry);
+
+    final credentials = AccessCredentials(aToken, 'refresh', ['scope']);
+    expect(credentials.accessToken, equals(aToken));
+    expect(credentials.refreshToken, equals('refresh'));
+    expect(credentials.scopes, equals(['scope']));
+  });
+
+  test('client-id', () {
+    final clientId = ClientId('id', 'secret');
+    expect(clientId.identifier, equals('id'));
+    expect(clientId.secret, equals('secret'));
+  });
+
+  group('service-account-credentials', () {
+    final clientId = ClientId.serviceAccount('id');
+
+    const credentials = {
+      'private_key_id': '301029',
+      'private_key': testPrivateKeyString,
+      'client_email': 'a@b.com',
+      'client_id': 'myid',
+      'type': 'service_account'
+    };
+
+    test('from-valid-individual-params', () {
+      final credentials =
+          ServiceAccountCredentials('email', clientId, testPrivateKeyString);
+      expect(credentials.email, equals('email'));
+      expect(credentials.clientId, equals(clientId));
+      expect(credentials.privateKey, equals(testPrivateKeyString));
+      expect(credentials.impersonatedUser, isNull);
+    });
+
+    test('from-valid-individual-params-with-user', () {
+      final credentials = ServiceAccountCredentials(
+          'email', clientId, testPrivateKeyString,
+          impersonatedUser: 'x@y.com');
+      expect(credentials.email, equals('email'));
+      expect(credentials.clientId, equals(clientId));
+      expect(credentials.privateKey, equals(testPrivateKeyString));
+      expect(credentials.impersonatedUser, equals('x@y.com'));
+    });
+
+    test('from-json-string', () {
+      final credentialsFromJson =
+          ServiceAccountCredentials.fromJson(jsonEncode(credentials));
+      expect(credentialsFromJson.email, equals('a@b.com'));
+      expect(credentialsFromJson.clientId.identifier, equals('myid'));
+      expect(credentialsFromJson.clientId.secret, isNull);
+      expect(credentialsFromJson.privateKey, equals(testPrivateKeyString));
+      expect(credentialsFromJson.impersonatedUser, isNull);
+    });
+
+    test('from-json-string-with-user', () {
+      final credentialsFromJson = ServiceAccountCredentials.fromJson(
+          jsonEncode(credentials),
+          impersonatedUser: 'x@y.com');
+      expect(credentialsFromJson.email, equals('a@b.com'));
+      expect(credentialsFromJson.clientId.identifier, equals('myid'));
+      expect(credentialsFromJson.clientId.secret, isNull);
+      expect(credentialsFromJson.privateKey, equals(testPrivateKeyString));
+      expect(credentialsFromJson.impersonatedUser, equals('x@y.com'));
+    });
+
+    test('from-json-map', () {
+      final credentialsFromJson =
+          ServiceAccountCredentials.fromJson(credentials);
+      expect(credentialsFromJson.email, equals('a@b.com'));
+      expect(credentialsFromJson.clientId.identifier, equals('myid'));
+      expect(credentialsFromJson.clientId.secret, isNull);
+      expect(credentialsFromJson.privateKey, equals(testPrivateKeyString));
+      expect(credentialsFromJson.impersonatedUser, isNull);
+    });
+
+    test('from-json-map-with-user', () {
+      final credentialsFromJson = ServiceAccountCredentials.fromJson(
+          credentials,
+          impersonatedUser: 'x@y.com');
+      expect(credentialsFromJson.email, equals('a@b.com'));
+      expect(credentialsFromJson.clientId.identifier, equals('myid'));
+      expect(credentialsFromJson.clientId.secret, isNull);
+      expect(credentialsFromJson.privateKey, equals(testPrivateKeyString));
+      expect(credentialsFromJson.impersonatedUser, equals('x@y.com'));
+    });
+  });
+
+  group('client-wrappers', () {
+    final clientId = ClientId('id', 'secret');
+    final tomorrow = DateTime.now().add(const Duration(days: 1)).toUtc();
+    final yesterday = DateTime.now().subtract(const Duration(days: 1)).toUtc();
+    final aToken = AccessToken('Bearer', 'bar', tomorrow);
+    final credentials = AccessCredentials(aToken, 'refresh', ['s1', 's2']);
+
+    Future<Response> successfulRefresh(Request request) {
+      expect(request.method, equals('POST'));
+      expect('${request.url}',
+          equals('https://accounts.google.com/o/oauth2/token'));
+      expect(
+          request.body,
+          equals('client_id=id&'
+              'client_secret=secret&'
+              'refresh_token=refresh&'
+              'grant_type=refresh_token'));
+      final body = jsonEncode({
+        'token_type': 'Bearer',
+        'access_token': 'atoken',
+        'expires_in': 3600,
+      });
+
+      return Future.value(Response(body, 200, headers: _jsonContentType));
+    }
+
+    Future<Response> refreshErrorResponse(Request request) {
+      final body = jsonEncode({'error': 'An error occured'});
+      return Future<Response>.value(
+          Response(body, 400, headers: _jsonContentType));
+    }
+
+    Future<Response> serverError(Request request) =>
+        Future<Response>.error(Exception('transport layer exception'));
+
+    test('refreshCredentials-successfull', () async {
+      final newCredentials = await refreshCredentials(clientId, credentials,
+          mockClient(expectAsync1(successfulRefresh), expectClose: false));
+      final expectedResultUtc = DateTime.now()
+          .toUtc()
+          .add(const Duration(seconds: 3600 - maxExpectedTimeDiffInSeconds));
+
+      final accessToken = newCredentials.accessToken;
+      expect(accessToken.type, equals('Bearer'));
+      expect(accessToken.data, equals('atoken'));
+      expect(accessToken.expiry.difference(expectedResultUtc).inSeconds,
+          equals(0));
+
+      expect(newCredentials.refreshToken, equals('refresh'));
+      expect(newCredentials.scopes, equals(['s1', 's2']));
+    });
+
+    test('refreshCredentials-http-error', () async {
+      try {
+        await refreshCredentials(
+            clientId, credentials, mockClient(serverError, expectClose: false));
+        fail('expected error');
+      } catch (error) {
+        expect(
+            error.toString(), equals('Exception: transport layer exception'));
+      }
+    });
+
+    test('refreshCredentials-error-response', () async {
+      try {
+        await refreshCredentials(clientId, credentials,
+            mockClient(refreshErrorResponse, expectClose: false));
+        fail('expected error');
+      } catch (error) {
+        expect(error is RefreshFailedException, isTrue);
+      }
+    });
+
+    group('authenticatedClient', () {
+      final url = Uri.parse('http://www.example.com');
+
+      test('successfull', () async {
+        final client = authenticatedClient(
+            mockClient(expectAsync1((request) {
+              expect(request.method, equals('POST'));
+              expect(request.url, equals(url));
+              expect(request.headers.length, equals(1));
+              expect(request.headers['Authorization'], equals('Bearer bar'));
+
+              return Future.value(Response('', 204));
+            }), expectClose: false),
+            credentials);
+        expect(client.credentials, equals(credentials));
+
+        final response = await client.send(RequestImpl('POST', url));
+        expect(response.statusCode, equals(204));
+      });
+
+      test('access-denied', () {
+        final client = authenticatedClient(
+            mockClient(expectAsync1((request) {
+              expect(request.method, equals('POST'));
+              expect(request.url, equals(url));
+              expect(request.headers.length, equals(1));
+              expect(request.headers['Authorization'], equals('Bearer bar'));
+
+              const headers = {'www-authenticate': 'foobar'};
+              return Future.value(Response('', 401, headers: headers));
+            }), expectClose: false),
+            credentials);
+        expect(client.credentials, equals(credentials));
+
+        expect(client.send(RequestImpl('POST', url)),
+            throwsA(isAccessDeniedException));
+      });
+
+      test('non-bearer-token', () {
+        final aToken = credentials.accessToken;
+        final nonBearerCredentials = AccessCredentials(
+            AccessToken('foobar', aToken.data, aToken.expiry),
+            'refresh',
+            ['s1', 's2']);
+
+        expect(
+            () => authenticatedClient(
+                mockClient(_defaultResponseHandler, expectClose: false),
+                nonBearerCredentials),
+            throwsA(isArgumentError));
+      });
+    });
+
+    group('autoRefreshingClient', () {
+      final url = Uri.parse('http://www.example.com');
+
+      test('up-to-date', () async {
+        final client = autoRefreshingClient(
+            clientId,
+            credentials,
+            mockClient(
+                expectAsync1((request) => Future.value(Response('', 200))),
+                expectClose: false));
+        expect(client.credentials, equals(credentials));
+
+        final response = await client.send(RequestImpl('POST', url));
+        expect(response.statusCode, equals(200));
+      });
+
+      test('no-refresh-token', () {
+        final credentials = AccessCredentials(
+            AccessToken('Bearer', 'bar', yesterday), null, ['s1', 's2']);
+
+        expect(
+            () => autoRefreshingClient(clientId, credentials,
+                mockClient(_defaultResponseHandler, expectClose: false)),
+            throwsA(isArgumentError));
+      });
+
+      test('refresh-failed', () {
+        final credentials = AccessCredentials(
+            AccessToken('Bearer', 'bar', yesterday), 'refresh', ['s1', 's2']);
+
+        final client = autoRefreshingClient(
+            clientId,
+            credentials,
+            mockClient(expectAsync1((request) {
+              // This should be a refresh request.
+              expect(request.headers['foo'], isNull);
+              return refreshErrorResponse(request);
+            }), expectClose: false));
+        expect(client.credentials, equals(credentials));
+
+        final request = RequestImpl('POST', url);
+        request.headers.addAll({'foo': 'bar'});
+        expect(client.send(request), throwsA(isRefreshFailedException));
+      });
+
+      test('invalid-content-type', () {
+        final credentials = AccessCredentials(
+            AccessToken('Bearer', 'bar', yesterday), 'refresh', ['s1', 's2']);
+
+        final client = autoRefreshingClient(
+            clientId,
+            credentials,
+            mockClient(expectAsync1((request) {
+              // This should be a refresh request.
+              expect(request.headers['foo'], isNull);
+              final headers = {'content-type': 'image/png'};
+
+              return Future.value(Response('', 200, headers: headers));
+            }), expectClose: false));
+        expect(client.credentials, equals(credentials));
+
+        final request = RequestImpl('POST', url);
+        request.headers.addAll({'foo': 'bar'});
+        expect(client.send(request), throwsA(isException));
+      });
+
+      test('successful-refresh', () async {
+        var serverInvocation = 0;
+
+        final credentials = AccessCredentials(
+            AccessToken('Bearer', 'bar', yesterday), 'refresh', ['s1']);
+
+        final client = autoRefreshingClient(
+            clientId,
+            credentials,
+            mockClient(
+                expectAsync1((request) {
+                  if (serverInvocation++ == 0) {
+                    // This should be a refresh request.
+                    expect(request.headers['foo'], isNull);
+                    return successfulRefresh(request);
+                  } else {
+                    // This is the real request.
+                    expect(request.headers['foo'], equals('bar'));
+                    return Future.value(Response('', 200));
+                  }
+                }, count: 2),
+                expectClose: false));
+        expect(client.credentials, equals(credentials));
+
+        var executed = false;
+        client.credentialUpdates.listen(expectAsync1((newCredentials) {
+          expect(newCredentials.accessToken.type, equals('Bearer'));
+          expect(newCredentials.accessToken.data, equals('atoken'));
+          executed = true;
+        }), onDone: expectAsync0(() {}));
+
+        final request = RequestImpl('POST', url);
+        request.headers.addAll({'foo': 'bar'});
+
+        final response = await client.send(request);
+        expect(response.statusCode, equals(200));
+
+        // The `client.send()` will have triggered a credentials refresh.
+        expect(executed, isTrue);
+
+        client.close();
+      });
+    });
+  });
+}
+
+const _jsonContentType = {'content-type': 'application/json'};
diff --git a/googleapis_auth/test/test_utils.dart b/googleapis_auth/test/test_utils.dart
new file mode 100644
index 0000000..e067a96
--- /dev/null
+++ b/googleapis_auth/test/test_utils.dart
@@ -0,0 +1,85 @@
+// 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.
+
+library googleapis_auth.test_utils;
+
+import 'dart:async';
+
+import 'package:googleapis_auth/auth.dart';
+import 'package:googleapis_auth/src/crypto/pem.dart';
+import 'package:googleapis_auth/src/utils.dart';
+import 'package:http/http.dart';
+import 'package:http/testing.dart';
+import 'package:test/test.dart';
+
+const Matcher isUserConsentException = TypeMatcher<UserConsentException>();
+
+const Matcher isRefreshFailedException = TypeMatcher<RefreshFailedException>();
+
+const Matcher isAccessDeniedException = TypeMatcher<AccessDeniedException>();
+
+const Matcher isTransportException = TypeMatcher<TransportException>();
+
+class TransportException implements Exception {}
+
+Client get transportFailure => MockClient(
+    expectAsync1((Request _) => Future<Response>.error(TransportException())));
+
+const testPrivateKeyString = '''-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEAuDOwXO14ltE1j2O0iDSuqtbw/1kMKjeiki3oehk2zNoUte42
+/s2rX15nYCkKtYG/r8WYvKzb31P4Uow1S4fFydKNWxgX4VtEjHgeqfPxeCL9wiJc
+9KkEt4fyhj1Jo7193gCLtovLAFwPzAMbFLiXWkfqalJ5Z77fOE4Mo7u4pEgxNPgL
+VFGe0cEOAsHsKlsze+m1pmPHwWNVTcoKe5o0hOzy6hCPgVc6me6Y7aO8Fb4OVg0l
+XQdQpWn2ikVBpzBcZ6InnYyJ/CJNa3WL1LJ65mmYnfHtKGoMqhLK48OReguwRwwF
+e9/2+8UcdZcN5rsvt7yg3ZrKNH8rx+wZ36sRewIDAQABAoIBAQCn1HCcOsHkqDlk
+rDOQ5m8+uRhbj4bF8GrvRWTL2q1TeF/mY2U4Q6wg+KK3uq1HMzCzthWz0suCb7+R
+dq4YY1ySxoSEuy8G5WFPmyJVNy6Lh1Yty6FmSZlCn1sZdD3kMoK8A0NIz5Xmffrm
+pu3Fs2ozl9K9jOeQ3xgC9RoPFLrm8lHJ45Vn+SnTxZnsXT6pwpg3TnFIx5ZinU8k
+l0Um1n80qD2QQDakQ5jyr2odAELLBDlyCkxAglBXAVt4nk9Kl6nxb4snd9dnrL70
+WjLynWQsDczaV9TZIl2hYkMud+9OLVlUUtB+0c5b0p2t2P0sLltDaq3H6pT6yu2G
+8E86J9IBAoGBAPJaTNV5ysVOFn+YwWwRztzrvNArUJkVq8abN0gGp3gUvDEZnvzK
+weF7+lfZzcwVRmQkL3mWLzzZvCx77RfulAzLi5iFuRBPhhhxAPDiDuyL9B7O81G/
+M/W5DPctGOyD/9cnLuh72oij0unc5MLSfzJf8wblpcjJnPBDqIVh6Qt9AoGBAMKT
+Gacf4iSj1xW+0wrnbZlDuyCl6Msptj8ePcvLQrFqQmBwsXmWgVR+gFc/1G3lRft0
+QC6chsmafQHIIPpaDjq3sQ01/tUu7LXL+g/Hw9XtUHbkg3sZIQBtC26rKdStfHNS
+KTvuCgn/dAJNjiohfhWMt9R4Q6E5FV6PqQHJzPJXAoGAC41qZDKuC8GxKNvrPG+M
+4NML6RBngySZT5pOhExs5zh10BFclshDfbAfOtjTCotpE5T1/mG+VrQ6WBSANMfW
+ntWFDfwx2ikwRzH7zX+5HmV9eYp75sWqgGgVyiKIMZ4JMARaJBLjU+gbQbKZ5P+L
+uKcCOq3vvSZ/KKTQ/6qvJTECgYBiWgbCgoxF5wdmd4Gn5llw+lqRYyur3hbACuJD
+rCe3FDYfF3euNRSEiDkJYTtYnWbldtqmdPpw14VOrEF3KqQ8q/Nz8RIx4jlGn6dz
+6I8mCIH+xv1q8MXMuFHqC9zmIxdgF2y+XVF3wkd6jodI5oscC3g0juHokbkqhkVw
+oPfWmwKBgBfR6jv0gWWeWTfkNwj+cMLHQV1uvz6JyLH5K4iISEDFxYkd37jrHB8A
+9hz9UDfmCbSs2j8CXDg7zCayM6tfu4Vtx+8S5g3oN6sa1JXFY1Os7SoXhTfX9M+7
+QpYYDJZwkgZrVQoKMIdCs9xfyVhZERq945NYLekwE1t2W+tOVBgR
+-----END RSA PRIVATE KEY-----''';
+
+final testPrivateKey = keyFromString(testPrivateKeyString);
+
+void expectExpiryOneHourFromNow(AccessToken accessToken) {
+  final now = DateTime.now().toUtc();
+  final diff = accessToken.expiry.difference(now).inSeconds -
+      (3600 - maxExpectedTimeDiffInSeconds);
+  expect(-2 <= diff && diff <= 2, isTrue);
+}
+
+Client mockClient(Future<Response> Function(Request _) requestHandler,
+        {bool expectClose = true}) =>
+    ExpectCloseMockClient(requestHandler, expectClose ? 1 : 0);
+
+/// A client which will keep the VM alive until `close()` was called.
+class ExpectCloseMockClient extends MockClient {
+  late Function _expectedToBeCalled;
+
+  ExpectCloseMockClient(
+      Future<Response> Function(Request _) requestHandler, int c)
+      : super(requestHandler) {
+    _expectedToBeCalled = expectAsync0(() {}, count: c);
+  }
+
+  @override
+  void close() {
+    super.close();
+    _expectedToBeCalled();
+  }
+}