Repository: pypi/warehouse

Issue:
Change username & API token prefix, to make Travis auth easier
Just wanted to share a potential pain point for folks using the new PyPI API tokens for authentication via a travis.yaml. The @token username needs to be wrapped in quotes and the `:` after `pypi` needs to be escaped to work, otherwise you hit a 403 error (thanks for the help tracking this down @ewdurbin).

If you're using the environment variables through Travis' UI, the following works:
```
deploy:
  provider: pypi
  user: "@token"  # quotes required for travis
  # server: https://test.pypi.org/legacy/  # uncomment to do a test deploy
  password:
    secure: $PASSWORD  # stored in travis env var, with `:` after `pypi` escaped (pypi\:)
  on:
    branch: master
  skip_existing: true
```

If you're encrypting the token via the command line, you can just wrap your token in quotes:
```travis encrypt "<your-pypi_token>" --add deploy.password [--com]```


Discussion:
A bit unfortunate that we chose something that requires quoting as the username. It'd be nice to not have this friction when folks migrate to API keys. 

Since we're still in the beta period, I think it would be reasonable to change this (but still support `@token` for a bit for backwards compatibility). Some options that would not require quoting:

* `$token`
* `^token`
* `=token`
@dstufft any thoughts here?

I'm +1 for updating the keys to `pypi-` or `pypi_` prefix and changing the username to something without an `@`.

I'm -1 on backward compatibility for longer than a few days. We know who have provisioned API tokens and can email them to give them a headsup 24 hours before disabling the older grammar.
Yeah, I should be clear: I meant backwards compatibility until ~the end of the beta period.
I'm +1 for updating keys & username, and -1 on backward compatibility for longer than a few days.

@woodruffw heads-up.
Thanks for the ping! I'll make a PR for these changes now.

Resolving patch:
diff --git a/warehouse/macaroons/auth_policy.py b/warehouse/macaroons/auth_policy.py
--- a/warehouse/macaroons/auth_policy.py
+++ b/warehouse/macaroons/auth_policy.py
@@ -38,7 +38,8 @@ def _extract_basic_macaroon(auth):
     except ValueError:
         return None
 
-    if auth_method != "@token":
+    # TODO: Remove @token as an acceptable token username (GH-6345)
+    if auth_method != "@token" and auth_method != "__token__":
         return None
 
     return auth
diff --git a/warehouse/macaroons/services.py b/warehouse/macaroons/services.py
--- a/warehouse/macaroons/services.py
+++ b/warehouse/macaroons/services.py
@@ -31,7 +31,7 @@ class DatabaseMacaroonService:
     def __init__(self, db_session):
         self.db = db_session
 
-    def _extract_raw_macaroon(self, raw_macaroon):
+    def _extract_raw_macaroon(self, prefixed_macaroon):
         """
         Returns the base64-encoded macaroon component of a PyPI macaroon,
         dropping the prefix.
@@ -39,13 +39,13 @@ def _extract_raw_macaroon(self, raw_macaroon):
         Returns None if the macaroon is None, has no prefix, or has the
         wrong prefix.
         """
-        if raw_macaroon is None:
+        if prefixed_macaroon is None:
             return None
 
-        try:
-            prefix, raw_macaroon = raw_macaroon.split(":", 1)
-        except ValueError:
-            return None
+        prefix, split, raw_macaroon = prefixed_macaroon.partition("-")
+        # TODO: Remove ':' as an acceptable delimiter for tokens (GH-6345)
+        if prefix != "pypi" or not split:
+            prefix, _, raw_macaroon = prefixed_macaroon.partition(":")
 
         if prefix != "pypi":
             return None
@@ -129,7 +129,7 @@ def create_macaroon(self, location, user_id, description, caveats):
             version=pymacaroons.MACAROON_V2,
         )
         m.add_first_party_caveat(json.dumps(caveats))
-        serialized_macaroon = f"pypi:{m.serialize()}"
+        serialized_macaroon = f"pypi-{m.serialize()}"
         return serialized_macaroon, dm
 
     def delete_macaroon(self, macaroon_id):


Test patch:
diff --git a/tests/unit/macaroons/test_auth_policy.py b/tests/unit/macaroons/test_auth_policy.py
--- a/tests/unit/macaroons/test_auth_policy.py
+++ b/tests/unit/macaroons/test_auth_policy.py
@@ -32,6 +32,7 @@
         ("maybeafuturemethod foobar", None),
         ("token foobar", "foobar"),
         ("basic QHRva2VuOmZvb2Jhcg==", "foobar"),  # "@token:foobar"
+        ("basic X190b2tlbl9fOmZvb2Jhcg==", "foobar"),  # "__token__:foobar"
     ],
 )
 def test_extract_http_macaroon(auth, result):
@@ -49,6 +50,7 @@ def test_extract_http_macaroon(auth, result):
         ("bm90YXJlYWx0b2tlbg==", None),  # "notarealtoken"
         ("QGJhZHVzZXI6Zm9vYmFy", None),  # "@baduser:foobar"
         ("QHRva2VuOmZvb2Jhcg==", "foobar"),  # "@token:foobar"
+        ("X190b2tlbl9fOmZvb2Jhcg==", "foobar"),  # "__token__:foobar"
     ],
 )
 def test_extract_basic_macaroon(auth, result):
diff --git a/tests/unit/macaroons/test_services.py b/tests/unit/macaroons/test_services.py
--- a/tests/unit/macaroons/test_services.py
+++ b/tests/unit/macaroons/test_services.py
@@ -45,6 +45,7 @@ def test_creation(self):
             ("noprefixhere", None),
             ("invalid:prefix", None),
             ("pypi:validprefix", "validprefix"),
+            ("pypi-validprefix", "validprefix"),
         ],
     )
     def test_extract_raw_macaroon(self, macaroon_service, raw_macaroon, result):
@@ -74,7 +75,7 @@ def test_find_userid_invalid_macaroon(self, macaroon_service):
             key=b"fake key",
             version=pymacaroons.MACAROON_V2,
         ).serialize()
-        raw_macaroon = f"pypi:{raw_macaroon}"
+        raw_macaroon = f"pypi-{raw_macaroon}"
 
         assert macaroon_service.find_userid(raw_macaroon) is None
 
@@ -107,7 +108,7 @@ def test_verify_no_macaroon(self, macaroon_service):
             key=b"fake key",
             version=pymacaroons.MACAROON_V2,
         ).serialize()
-        raw_macaroon = f"pypi:{raw_macaroon}"
+        raw_macaroon = f"pypi-{raw_macaroon}"
 
         with pytest.raises(services.InvalidMacaroon):
             macaroon_service.verify(