Verify token characters when added and when used (#3732)

diff --git a/doc/repository-spec-v2.md b/doc/repository-spec-v2.md
index 06ad714..9104428 100644
--- a/doc/repository-spec-v2.md
+++ b/doc/repository-spec-v2.md
@@ -133,6 +133,10 @@
 authentication can only be used when `<hosted-url>` uses HTTPS. For further
 details on token management see: `dart pub token --help`.
 
+The tokens are inserted verbatim in the header, therefore they have to adhere to
+ https://www.rfc-editor.org/rfc/rfc6750#section-2.1. This means they must match
+ the regex: `^[a-zA-Z0-9._~+/=-]+$`.
+
 
 ### Missing Authentication or Invalid Token
 If the server requires authentication and the request does not carry an
diff --git a/lib/src/authentication/credential.dart b/lib/src/authentication/credential.dart
index 9f7ce0d..a59c1d0 100644
--- a/lib/src/authentication/credential.dart
+++ b/lib/src/authentication/credential.dart
@@ -6,6 +6,7 @@
 
 import '../exceptions.dart';
 import '../source/hosted.dart';
+import '../utils.dart';
 
 /// Token is a structure for storing authentication credentials for third-party
 /// pub registries. A token holds registry [url], credential [kind] and [token]
@@ -116,19 +117,26 @@
       );
     }
 
+    final String tokenValue;
     final environment = env;
     if (environment != null) {
       final value = Platform.environment[environment];
       if (value == null) {
-        throw DataException(
+        dataError(
           'Saved credential for "$url" pub repository requires environment '
           'variable named "$env" but not defined.',
         );
       }
-      return Future.value('Bearer $value');
+      tokenValue = value;
+    } else {
+      tokenValue = token!;
+    }
+    if (!isValidBearerToken(tokenValue)) {
+      dataError('Credential token for $url is not a valid Bearer token. '
+          'It should match `^[a-zA-Z0-9._~+/=-]+\$`');
     }
 
-    return Future.value('Bearer $token');
+    return Future.value('Bearer $tokenValue');
   }
 
   /// Returns whether or not given [url] could be authenticated using this
@@ -144,6 +152,14 @@
   // Either [token] or [env] should be defined to be valid.
   bool isValid() => (token == null) ^ (env == null);
 
+  /// Whether [candidate] can be used as a bearer token.
+  ///
+  /// We limit tokens to be valid bearer tokens according to
+  /// https://www.rfc-editor.org/rfc/rfc6750#section-2.1
+  static bool isValidBearerToken(String candidate) {
+    return RegExp(r'^[a-zA-Z0-9._~+/=-]+$').hasMatch(candidate);
+  }
+
   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 be9d406..86f032f 100644
--- a/lib/src/command/token_add.dart
+++ b/lib/src/command/token_add.dart
@@ -11,6 +11,7 @@
 import '../io.dart';
 import '../log.dart' as log;
 import '../source/hosted.dart';
+import '../utils.dart';
 
 /// Handles the `token add` pub command.
 class TokenAddCommand extends PubCommand {
@@ -69,6 +70,11 @@
       usageException('Token is not provided.');
     }
 
+    if (!Credential.isValidBearerToken(token)) {
+      dataError('The entered token is not a valid Bearer token. '
+          'A token may only contain `a-zA-Z0-9._~+/=-`');
+    }
+
     tokenStore.addCredential(Credential.token(hostedUrl, token));
     log.message(
       'Requests to "$hostedUrl" will now be authenticated using the secret '
diff --git a/test/directory_option_test.dart b/test/directory_option_test.dart
index ac61887..5bc1cfd 100644
--- a/test/directory_option_test.dart
+++ b/test/directory_option_test.dart
@@ -18,7 +18,7 @@
       ..serve('foo', '1.0.0')
       ..serve('foo', '0.1.2')
       ..serve('bar', '1.2.3');
-    await credentialsFile(globalServer, 'access token').create();
+    await credentialsFile(globalServer, 'access-token').create();
     globalServer.handle(
       RegExp('/api/packages/test_pkg/uploaders'),
       (request) {
diff --git a/test/lish/archives_and_uploads_a_package_test.dart b/test/lish/archives_and_uploads_a_package_test.dart
index ffad2ea..dcd66cc 100644
--- a/test/lish/archives_and_uploads_a_package_test.dart
+++ b/test/lish/archives_and_uploads_a_package_test.dart
@@ -18,7 +18,7 @@
   test('archives and uploads a package', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
@@ -44,7 +44,7 @@
     await d.tokensFile({
       'version': 1,
       'hosted': [
-        {'url': globalServer.url, 'token': 'access token'},
+        {'url': globalServer.url, 'token': 'access-token'},
       ]
     }).create();
     var pub = await startPublish(globalServer);
@@ -79,7 +79,7 @@
       globalServer,
       path: '/sub/folder',
       overrideDefaultHostedServer: false,
-      environment: {'TOKEN': 'access token'},
+      environment: {'TOKEN': 'access-token'},
     );
 
     await confirmPublish(pub);
@@ -124,7 +124,7 @@
     await d.dir(p.join(appPath, 'empty')).create();
 
     await servePackages();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/cloud_storage_upload_doesnt_redirect_test.dart b/test/lish/cloud_storage_upload_doesnt_redirect_test.dart
index 19b8761..fd14f85 100644
--- a/test/lish/cloud_storage_upload_doesnt_redirect_test.dart
+++ b/test/lish/cloud_storage_upload_doesnt_redirect_test.dart
@@ -13,7 +13,7 @@
   test("cloud storage upload doesn't redirect", () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/cloud_storage_upload_provides_an_error_test.dart b/test/lish/cloud_storage_upload_provides_an_error_test.dart
index fa3a783..78b783e 100644
--- a/test/lish/cloud_storage_upload_provides_an_error_test.dart
+++ b/test/lish/cloud_storage_upload_provides_an_error_test.dart
@@ -13,7 +13,7 @@
   test('cloud storage upload provides an error', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/does_not_include_dot_file.dart b/test/lish/does_not_include_dot_file.dart
index ab0b8a8..2699c81 100644
--- a/test/lish/does_not_include_dot_file.dart
+++ b/test/lish/does_not_include_dot_file.dart
@@ -30,7 +30,7 @@
 
   test('Check if package doesn\'t include dot-files', () async {
     await servePackages();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/does_not_include_pubspec_overrides_file.dart b/test/lish/does_not_include_pubspec_overrides_file.dart
index 9ff584b..126eeb1 100644
--- a/test/lish/does_not_include_pubspec_overrides_file.dart
+++ b/test/lish/does_not_include_pubspec_overrides_file.dart
@@ -31,7 +31,7 @@
 
   test('Check if package doesn\'t include pubspec_overrides.yaml', () async {
     await servePackages();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/force_publishes_if_tests_are_no_warnings_or_errors_test.dart b/test/lish/force_publishes_if_tests_are_no_warnings_or_errors_test.dart
index c287c31..882fbf0 100644
--- a/test/lish/force_publishes_if_tests_are_no_warnings_or_errors_test.dart
+++ b/test/lish/force_publishes_if_tests_are_no_warnings_or_errors_test.dart
@@ -16,7 +16,7 @@
   test('--force publishes if there are no warnings or errors', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer, args: ['--force']);
 
     handleUploadForm(globalServer);
diff --git a/test/lish/force_publishes_if_there_are_warnings_test.dart b/test/lish/force_publishes_if_there_are_warnings_test.dart
index 62e8754..cfc4567 100644
--- a/test/lish/force_publishes_if_there_are_warnings_test.dart
+++ b/test/lish/force_publishes_if_there_are_warnings_test.dart
@@ -27,7 +27,7 @@
 
     (await servePackages()).serve('foo', '1.0.0');
 
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer, args: ['--force']);
 
     handleUploadForm(globalServer);
diff --git a/test/lish/many_files_test.dart b/test/lish/many_files_test.dart
index d3805d1..7d9940e 100644
--- a/test/lish/many_files_test.dart
+++ b/test/lish/many_files_test.dart
@@ -40,7 +40,7 @@
       ],
     ).create();
     await servePackages();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
     pub.stdin.writeln('y');
     handleUploadForm(globalServer);
@@ -107,7 +107,7 @@
     }
 
     await servePackages();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/package_creation_provides_a_malformed_error_test.dart b/test/lish/package_creation_provides_a_malformed_error_test.dart
index cb18e56..491fb6e 100644
--- a/test/lish/package_creation_provides_a_malformed_error_test.dart
+++ b/test/lish/package_creation_provides_a_malformed_error_test.dart
@@ -15,7 +15,7 @@
   test('package creation provides a malformed error', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/package_creation_provides_a_malformed_success_test.dart b/test/lish/package_creation_provides_a_malformed_success_test.dart
index b65317c..3166285 100644
--- a/test/lish/package_creation_provides_a_malformed_success_test.dart
+++ b/test/lish/package_creation_provides_a_malformed_success_test.dart
@@ -15,7 +15,7 @@
   test('package creation provides a malformed success', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/package_creation_provides_an_error_test.dart b/test/lish/package_creation_provides_an_error_test.dart
index c1d8bba..e7dbda4 100644
--- a/test/lish/package_creation_provides_an_error_test.dart
+++ b/test/lish/package_creation_provides_an_error_test.dart
@@ -15,7 +15,7 @@
   test('package creation provides an error', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/package_creation_provides_invalid_json_test.dart b/test/lish/package_creation_provides_invalid_json_test.dart
index 124e01a..39c8e45 100644
--- a/test/lish/package_creation_provides_invalid_json_test.dart
+++ b/test/lish/package_creation_provides_invalid_json_test.dart
@@ -13,7 +13,7 @@
   test('package creation provides invalid JSON', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/package_validation_has_a_warning_and_continues_test.dart b/test/lish/package_validation_has_a_warning_and_continues_test.dart
index 5355434..a5c083b 100644
--- a/test/lish/package_validation_has_a_warning_and_continues_test.dart
+++ b/test/lish/package_validation_has_a_warning_and_continues_test.dart
@@ -22,7 +22,7 @@
     File(d.path(p.join(appPath, 'README.md'))).deleteSync();
 
     await servePackages();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
     expect(pub.stdout, emitsThrough(startsWith('Package has 1 warning.')));
     pub.stdin.writeln('y');
diff --git a/test/lish/upload_form_fields_has_a_non_string_value_test.dart b/test/lish/upload_form_fields_has_a_non_string_value_test.dart
index 8758bca..7793863 100644
--- a/test/lish/upload_form_fields_has_a_non_string_value_test.dart
+++ b/test/lish/upload_form_fields_has_a_non_string_value_test.dart
@@ -14,7 +14,7 @@
   test('upload form fields has a non-string value', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/upload_form_fields_is_not_a_map_test.dart b/test/lish/upload_form_fields_is_not_a_map_test.dart
index dc41511..d0efb0d 100644
--- a/test/lish/upload_form_fields_is_not_a_map_test.dart
+++ b/test/lish/upload_form_fields_is_not_a_map_test.dart
@@ -14,7 +14,7 @@
   test('upload form fields is not a map', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/upload_form_is_missing_fields_test.dart b/test/lish/upload_form_is_missing_fields_test.dart
index e909c0a..fc494ca 100644
--- a/test/lish/upload_form_is_missing_fields_test.dart
+++ b/test/lish/upload_form_is_missing_fields_test.dart
@@ -14,7 +14,7 @@
   test('upload form is missing fields', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/upload_form_is_missing_url_test.dart b/test/lish/upload_form_is_missing_url_test.dart
index 418813f..e4f0291 100644
--- a/test/lish/upload_form_is_missing_url_test.dart
+++ b/test/lish/upload_form_is_missing_url_test.dart
@@ -14,7 +14,7 @@
   test('upload form is missing url', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/upload_form_provides_an_error_test.dart b/test/lish/upload_form_provides_an_error_test.dart
index 048ca17..2eacb24 100644
--- a/test/lish/upload_form_provides_an_error_test.dart
+++ b/test/lish/upload_form_provides_an_error_test.dart
@@ -14,7 +14,7 @@
   test('upload form provides an error', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/upload_form_provides_invalid_json_test.dart b/test/lish/upload_form_provides_invalid_json_test.dart
index 03f1551..14862bd 100644
--- a/test/lish/upload_form_provides_invalid_json_test.dart
+++ b/test/lish/upload_form_provides_invalid_json_test.dart
@@ -13,7 +13,7 @@
     await servePackages();
     await d.validPackage.create();
     await servePackages();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/upload_form_url_is_not_a_string_test.dart b/test/lish/upload_form_url_is_not_a_string_test.dart
index b37d24f..45e9aa8 100644
--- a/test/lish/upload_form_url_is_not_a_string_test.dart
+++ b/test/lish/upload_form_url_is_not_a_string_test.dart
@@ -14,7 +14,7 @@
   test('upload form url is not a string', () async {
     await servePackages();
     await d.validPackage.create();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/lish/utils.dart b/test/lish/utils.dart
index 03766a4..10d21e5 100644
--- a/test/lish/utils.dart
+++ b/test/lish/utils.dart
@@ -13,7 +13,7 @@
   server.expect('GET', '$path/api/packages/versions/new', (request) {
     expect(
       request.headers,
-      containsPair('authorization', 'Bearer access token'),
+      containsPair('authorization', 'Bearer access-token'),
     );
 
     body ??= {
diff --git a/test/oauth2/logout_test.dart b/test/oauth2/logout_test.dart
index f5c8619..6c835a9 100644
--- a/test/oauth2/logout_test.dart
+++ b/test/oauth2/logout_test.dart
@@ -13,7 +13,7 @@
     await d
         .credentialsFile(
           globalServer,
-          'access token',
+          'access-token',
           refreshToken: 'refresh token',
           expiration: DateTime.now().add(Duration(hours: 1)),
         )
@@ -31,7 +31,7 @@
     await d
         .credentialsFile(
           globalServer,
-          'access token',
+          'access-token',
           refreshToken: 'refresh token',
           expiration: DateTime.now().add(Duration(hours: 1)),
         )
@@ -40,7 +40,7 @@
     await d
         .legacyCredentialsFile(
           globalServer,
-          'access token',
+          'access-token',
           refreshToken: 'refresh token',
           expiration: DateTime.now().add(Duration(hours: 1)),
         )
diff --git a/test/oauth2/utils.dart b/test/oauth2/utils.dart
index f7043a0..05636e2 100644
--- a/test/oauth2/utils.dart
+++ b/test/oauth2/utils.dart
@@ -16,7 +16,7 @@
 Future authorizePub(
   TestProcess pub,
   PackageServer server, [
-  String accessToken = 'access token',
+  String accessToken = 'access-token',
 ]) async {
   await expectLater(
     pub.stdout,
diff --git a/test/oauth2/with_a_pre_existing_credentials_does_not_authenticate_test.dart b/test/oauth2/with_a_pre_existing_credentials_does_not_authenticate_test.dart
index 3c22994..516204c 100644
--- a/test/oauth2/with_a_pre_existing_credentials_does_not_authenticate_test.dart
+++ b/test/oauth2/with_a_pre_existing_credentials_does_not_authenticate_test.dart
@@ -12,7 +12,7 @@
     await d.validPackage.create();
 
     await servePackages();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/oauth2/with_a_server_rejected_refresh_token_authenticates_again_test.dart b/test/oauth2/with_a_server_rejected_refresh_token_authenticates_again_test.dart
index 7e38887..2237814 100644
--- a/test/oauth2/with_a_server_rejected_refresh_token_authenticates_again_test.dart
+++ b/test/oauth2/with_a_server_rejected_refresh_token_authenticates_again_test.dart
@@ -23,7 +23,7 @@
     await d
         .credentialsFile(
           globalServer,
-          'access token',
+          'access-token',
           refreshToken: 'bad refresh token',
           expiration: DateTime.now().subtract(Duration(hours: 1)),
         )
diff --git a/test/oauth2/with_an_expired_credentials_refreshes_and_saves_test.dart b/test/oauth2/with_an_expired_credentials_refreshes_and_saves_test.dart
index c4a54e1..bca9feb 100644
--- a/test/oauth2/with_an_expired_credentials_refreshes_and_saves_test.dart
+++ b/test/oauth2/with_an_expired_credentials_refreshes_and_saves_test.dart
@@ -20,7 +20,7 @@
     await d
         .credentialsFile(
           globalServer,
-          'access token',
+          'access-token',
           refreshToken: 'refresh token',
           expiration: DateTime.now().subtract(Duration(hours: 1)),
         )
diff --git a/test/oauth2/with_an_expired_credentials_without_a_refresh_token_authenticates_again_test.dart b/test/oauth2/with_an_expired_credentials_without_a_refresh_token_authenticates_again_test.dart
index ac22d7f..b21b496 100644
--- a/test/oauth2/with_an_expired_credentials_without_a_refresh_token_authenticates_again_test.dart
+++ b/test/oauth2/with_an_expired_credentials_without_a_refresh_token_authenticates_again_test.dart
@@ -19,7 +19,7 @@
     await d
         .credentialsFile(
           globalServer,
-          'access token',
+          'access-token',
           expiration: DateTime.now().subtract(Duration(hours: 1)),
         )
         .create();
diff --git a/test/oauth2/with_no_credentials_authenticates_and_saves_credentials_test.dart b/test/oauth2/with_no_credentials_authenticates_and_saves_credentials_test.dart
index c4d61ed..abe365f 100644
--- a/test/oauth2/with_no_credentials_authenticates_and_saves_credentials_test.dart
+++ b/test/oauth2/with_no_credentials_authenticates_and_saves_credentials_test.dart
@@ -32,6 +32,6 @@
     // do so rather than killing it so it'll write out the credentials file.
     await pub.shouldExit(1);
 
-    await d.credentialsFile(globalServer, 'access token').validate();
+    await d.credentialsFile(globalServer, 'access-token').validate();
   });
 }
diff --git a/test/oauth2/with_server_rejected_credentials_authenticates_again_test.dart b/test/oauth2/with_server_rejected_credentials_authenticates_again_test.dart
index 03468cf..c4b8d5b 100644
--- a/test/oauth2/with_server_rejected_credentials_authenticates_again_test.dart
+++ b/test/oauth2/with_server_rejected_credentials_authenticates_again_test.dart
@@ -16,7 +16,7 @@
       'credentials.json', () async {
     await d.validPackage.create();
     await servePackages();
-    await d.credentialsFile(globalServer, 'access token').create();
+    await d.credentialsFile(globalServer, 'access-token').create();
     var pub = await startPublish(globalServer);
 
     await confirmPublish(pub);
diff --git a/test/token/add_token_test.dart b/test/token/add_token_test.dart
index dd5d690..4321d90 100644
--- a/test/token/add_token_test.dart
+++ b/test/token/add_token_test.dart
@@ -129,6 +129,19 @@
     await d.dir(configPath, [d.nothing('pub-tokens.json')]).validate();
   });
 
+  test('with invalid token returns error', () async {
+    await d.dir(configPath).create();
+
+    await runPub(
+      args: ['token', 'add', 'https://pub.dev'],
+      error: contains('The entered token is not a valid Bearer token.'),
+      input: ['auth-token@'], // '@' is not allowed in bearer tokens
+      exitCode: exit_codes.DATA,
+    );
+
+    await d.dir(configPath, [d.nothing('pub-tokens.json')]).validate();
+  });
+
   test('with non-secure server url returns error', () async {
     await d.dir(configPath).create();
     await runPub(
diff --git a/test/token/error_message_test.dart b/test/token/error_message_test.dart
index 0ccce60..5ed36dc 100644
--- a/test/token/error_message_test.dart
+++ b/test/token/error_message_test.dart
@@ -34,7 +34,7 @@
     await d.tokensFile({
       'version': 1,
       'hosted': [
-        {'url': globalServer.url, 'token': 'access token'},
+        {'url': globalServer.url, 'token': 'access-token'},
       ]
     }).create();
   });
diff --git a/test/token/token_authentication_test.dart b/test/token/token_authentication_test.dart
index d0b89c9..7bd74f1 100644
--- a/test/token/token_authentication_test.dart
+++ b/test/token/token_authentication_test.dart
@@ -2,6 +2,7 @@
 // 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 'package:pub/src/exit_codes.dart' as exit_codes;
 import 'package:test/test.dart';
 
 import '../descriptor.dart' as d;
@@ -21,7 +22,7 @@
     var pub = await startPublish(
       globalServer,
       overrideDefaultHostedServer: false,
-      environment: {'TOKEN': 'access token'},
+      environment: {'TOKEN': 'access-token'},
     );
     await confirmPublish(pub);
 
@@ -30,13 +31,60 @@
     await pub.shouldExit(1);
   });
 
+  test('with a invalid environment token fails with error', () async {
+    await servePackages();
+    await d.validPackage.create();
+    await d.tokensFile({
+      'version': 1,
+      'hosted': [
+        {'url': globalServer.url, 'env': 'TOKEN'},
+      ]
+    }).create();
+    await runPub(
+      args: ['publish'],
+      environment: {
+        'TOKEN': 'access-token@' // '@' is not allowed in bearer tokens
+      },
+      error: contains(
+        'Credential token for ${globalServer.url} is not a valid Bearer token.',
+      ),
+      exitCode: exit_codes.DATA,
+    );
+  });
+
+  test('with a pre existing invalid opaque token fails with error', () async {
+    await servePackages();
+    await d.validPackage.create();
+    await d.tokensFile({
+      'version': 1,
+      'hosted': [
+        // Corrupted files can be created by earlier pub versions that did not
+        // validate, or by manual edits.
+        {
+          'url': globalServer.url,
+          'token': 'access-token@', // '@' is not allowed in bearer tokens
+        },
+      ]
+    }).create();
+    await runPub(
+      args: ['publish'],
+      environment: {
+        'TOKEN': 'access-token@' // '@' is not allowed in bearer tokens
+      },
+      error: contains(
+        'Credential token for ${globalServer.url} is not a valid Bearer token.',
+      ),
+      exitCode: exit_codes.DATA,
+    );
+  });
+
   test('with a pre existing opaque token authenticates', () async {
     await servePackages();
     await d.validPackage.create();
     await d.tokensFile({
       'version': 1,
       'hosted': [
-        {'url': globalServer.url, 'token': 'access token'},
+        {'url': globalServer.url, 'token': 'access-token'},
       ]
     }).create();
     var pub = await startPublish(
diff --git a/test/token/when_receives_401_removes_token_test.dart b/test/token/when_receives_401_removes_token_test.dart
index 6ab0700..9e4d187 100644
--- a/test/token/when_receives_401_removes_token_test.dart
+++ b/test/token/when_receives_401_removes_token_test.dart
@@ -15,7 +15,7 @@
     await d.tokensFile({
       'version': 1,
       'hosted': [
-        {'url': server.url, 'token': 'access token'},
+        {'url': server.url, 'token': 'access-token'},
       ]
     }).create();
     var pub = await startPublish(server, overrideDefaultHostedServer: false);
diff --git a/test/token/when_receives_403_persists_saved_token_test.dart b/test/token/when_receives_403_persists_saved_token_test.dart
index 3fef9a5..2141173 100644
--- a/test/token/when_receives_403_persists_saved_token_test.dart
+++ b/test/token/when_receives_403_persists_saved_token_test.dart
@@ -15,7 +15,7 @@
     await d.tokensFile({
       'version': 1,
       'hosted': [
-        {'url': server.url, 'token': 'access token'},
+        {'url': server.url, 'token': 'access-token'},
       ]
     }).create();
     var pub = await startPublish(server, overrideDefaultHostedServer: false);
@@ -30,7 +30,7 @@
     await d.tokensFile({
       'version': 1,
       'hosted': [
-        {'url': server.url, 'token': 'access token'},
+        {'url': server.url, 'token': 'access-token'},
       ]
     }).validate();
   });