Environment credentials support (#3115)

* Environment credentials support
diff --git a/lib/src/authentication/credential.dart b/lib/src/authentication/credential.dart
index d840f6b..a44cc91 100644
--- a/lib/src/authentication/credential.dart
+++ b/lib/src/authentication/credential.dart
@@ -4,6 +4,8 @@
 
 // @dart=2.11
 
+import 'dart:io';
+
 import 'package:meta/meta.dart';
 
 import '../exceptions.dart';
@@ -26,13 +28,21 @@
   /// Internal constructor that's only used by [fromJson].
   Credential._internal({
     @required this.url,
-    @required this.token,
     @required this.unknownFields,
+    @required this.token,
+    @required this.env,
   });
 
-  /// Create a new [Credential].
+  /// Create credential that stores clear text token.
   Credential.token(this.url, this.token)
-      : unknownFields = const <String, dynamic>{};
+      : env = null,
+        unknownFields = const <String, dynamic>{};
+
+  /// Create credential that stores environment variable name that stores token
+  /// value.
+  Credential.env(this.url, this.env)
+      : token = null,
+        unknownFields = const <String, dynamic>{};
 
   /// Deserialize [json] into [Credential] type.
   ///
@@ -44,14 +54,29 @@
 
     final hostedUrl = validateAndNormalizeHostedUrl(json['url'] as String);
 
-    const knownKeys = {'url', 'token'};
+    const knownKeys = {'url', 'token', 'env'};
     final unknownFields = Map.fromEntries(
         json.entries.where((kv) => !knownKeys.contains(kv.key)));
 
+    /// Returns [String] value from [json] at [key] index or `null` if [json]
+    /// doesn't contains [key].
+    ///
+    /// Throws [FormatException] if value type is not [String].
+    String _string(String key) {
+      if (json.containsKey(key)) {
+        if (json[key] is! String) {
+          throw FormatException('Provided $key value should be string');
+        }
+        return json[key] as String;
+      }
+      return null;
+    }
+
     return Credential._internal(
       url: hostedUrl,
-      token: json['token'] is String ? json['token'] as String : null,
       unknownFields: unknownFields,
+      token: _string('token'),
+      env: _string('env'),
     );
   }
 
@@ -61,6 +86,9 @@
   /// Authentication token value
   final String token;
 
+  /// Environment variable name that stores token value
+  final String env;
+
   /// Unknown fields found in pub-tokens.json. The fields might be created by the
   /// future version of pub tool. We don't want to override them when using the
   /// old SDK.
@@ -71,6 +99,7 @@
     return <String, dynamic>{
       'url': url.toString(),
       if (token != null) 'token': token,
+      if (env != null) 'env': env,
       ...unknownFields,
     };
   }
@@ -90,6 +119,17 @@
       );
     }
 
+    if (env != null) {
+      final value = Platform.environment[env];
+      if (value == null) {
+        throw DataException(
+          'Saved credential for "$url" pub repository requires environment '
+          'variable named "$env" but not defined.',
+        );
+      }
+      return Future.value('Bearer $value');
+    }
+
     return Future.value('Bearer $token');
   }
 
@@ -103,7 +143,8 @@
   ///
   /// This method might return `false` when a `pub-tokens.json` file created by
   /// future SDK used by pub tool from old SDK.
-  bool isValid() => token != null;
+  // Either [token] or [env] should be defined to be valid.
+  bool isValid() => (token == null) ^ (env == null);
 
   static String _normalizeUrl(String url) {
     return (url.endsWith('/') ? url : '$url/').toLowerCase();
diff --git a/lib/src/command/token_add.dart b/lib/src/command/token_add.dart
index b00ffaf..f7cd6b9 100644
--- a/lib/src/command/token_add.dart
+++ b/lib/src/command/token_add.dart
@@ -5,6 +5,7 @@
 // @dart=2.11
 
 import 'dart:async';
+import 'dart:io';
 
 import '../authentication/credential.dart';
 import '../command.dart';
@@ -25,6 +26,14 @@
   @override
   String get argumentsDescription => '[hosted-url]';
 
+  String get envVar => argResults['env-var'];
+
+  TokenAddCommand() {
+    argParser.addOption('env-var',
+        help: 'Read the secret token from this environment variable when '
+            'making requests.');
+  }
+
   @override
   Future<void> runProtected() async {
     if (argResults.rest.isEmpty) {
@@ -40,17 +49,11 @@
         throw DataException('Insecure package repository could not be added.');
       }
 
-      final token = await stdinPrompt('Enter secret token:', echoMode: false)
-          .timeout(const Duration(minutes: 15));
-      if (token.isEmpty) {
-        usageException('Token is not provided.');
+      if (envVar == null) {
+        await _addTokenFromStdin(hostedUrl);
+      } else {
+        await _addEnvVarToken(hostedUrl);
       }
-
-      tokenStore.addCredential(Credential.token(hostedUrl, token));
-      log.message(
-        'Requests to $hostedUrl will now be authenticated using the secret '
-        'token.',
-      );
     } on FormatException catch (e) {
       usageException('Invalid [hosted-url]: "${argResults.rest.first}"\n'
           '${e.message}');
@@ -61,4 +64,37 @@
       throw ApplicationException('Token is not provided within 15 minutes.');
     }
   }
+
+  Future<void> _addTokenFromStdin(Uri hostedUrl) async {
+    final token = await stdinPrompt('Enter secret token:', echoMode: false)
+        .timeout(const Duration(minutes: 15));
+    if (token.isEmpty) {
+      usageException('Token is not provided.');
+    }
+
+    tokenStore.addCredential(Credential.token(hostedUrl, token));
+    log.message(
+      'Requests to "$hostedUrl" will now be authenticated using the secret '
+      'token.',
+    );
+  }
+
+  Future<void> _addEnvVarToken(Uri hostedUrl) async {
+    if (envVar.isEmpty) {
+      throw DataException('Cannot use the empty string as --env-var');
+    }
+
+    tokenStore.addCredential(Credential.env(hostedUrl, envVar));
+    log.message(
+      'Requests to "$hostedUrl" will now be authenticated using the secret '
+      'token stored in the environment variable `$envVar`.',
+    );
+
+    if (!Platform.environment.containsKey(envVar)) {
+      // If environment variable doesn't exist when
+      // pub token add <hosted-url> --env-var <ENV_VAR> is called, we should
+      // print a warning.
+      log.warning('Environment variable `$envVar` is not defined.');
+    }
+  }
 }
diff --git a/test/token/add_token_test.dart b/test/token/add_token_test.dart
index 536b295..8105ce5 100644
--- a/test/token/add_token_test.dart
+++ b/test/token/add_token_test.dart
@@ -34,6 +34,52 @@
     }).validate();
   });
 
+  group('with environment variable creates tokens.json that contains env var',
+      () {
+    test('without environment variable provided', () async {
+      await d.tokensFile({
+        'version': 1,
+        'hosted': [
+          {'url': 'https://example.com', 'token': 'abc'},
+        ]
+      }).create();
+
+      await runPub(
+        args: ['token', 'add', 'https://example.com/', '--env-var', 'TOKEN'],
+        error: 'Environment variable `TOKEN` is not defined.',
+      );
+
+      await d.tokensFile({
+        'version': 1,
+        'hosted': [
+          {'url': 'https://example.com', 'env': 'TOKEN'},
+        ]
+      }).validate();
+    });
+
+    test('with environment variable provided', () async {
+      await d.tokensFile({
+        'version': 1,
+        'hosted': [
+          {'url': 'https://example.com', 'token': 'abc'},
+        ]
+      }).create();
+
+      await runPub(
+        args: ['token', 'add', 'https://example.com/', '--env-var', 'TOKEN'],
+        environment: {'TOKEN': 'secret'},
+        error: isNot(contains('Environment variable TOKEN is not defined.')),
+      );
+
+      await d.tokensFile({
+        'version': 1,
+        'hosted': [
+          {'url': 'https://example.com', 'env': 'TOKEN'},
+        ]
+      }).validate();
+    });
+  });
+
   test('persists unknown fields on unmodified entries', () async {
     await d.tokensFile({
       'version': 1,