← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-api-complete-authorization into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-api-complete-authorization into lp:launchpad.

Commit message:
Rearrange API-based snap authorization: Snap.beginAuthorization no longer takes a URL and now returns a caveat ID, and clients now need to call Snap.completeAuthorization too.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-api-complete-authorization/+merge/313396

I realised today that the previous design of Snap.beginAuthorization was unworkable for third-party sites such as build.snapcraft.io where the client browser is not expected to possess Launchpad credentials, because the end of the OpenID exchange required being able to use Snap:+authorize, which requires launchpad.Edit on the Snap.

Instead of relying on Launchpad's own OpenID exchange in this case, a much simpler solution (and also one that will probably integrate better with other sites) is to return only the information needed to start the OpenID exchange and rely on the calling site to run the whole thing itself.  This requires Snap.beginAuthorization to return the SSO caveat ID to the caller (which it already did, just embedded in a URL), and for there to be a new Snap.completeAuthorization method that the calling site is required to call with the discharge macaroon when the exchange is complete.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-api-complete-authorization into lp:launchpad.
=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2016-12-08 17:39:19 +0000
+++ lib/lp/snappy/browser/snap.py	2016-12-15 20:23:14 +0000
@@ -16,6 +16,8 @@
     'SnapView',
     ]
 
+from urllib import urlencode
+
 from lazr.restful.fields import Reference
 from lazr.restful.interface import (
     copy_field,
@@ -56,7 +58,6 @@
 from lp.registry.enums import VCSType
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.services.features import getFeatureFlag
-from lp.services.fields import URIField
 from lp.services.helpers import english_list
 from lp.services.propertycache import cachedproperty
 from lp.services.scripts import log
@@ -76,6 +77,7 @@
     Breadcrumb,
     NameBreadcrumb,
     )
+from lp.services.webapp.url import urlappend
 from lp.services.webhooks.browser import WebhookTargetNavigationMixin
 from lp.snappy.browser.widgets.snaparchive import SnapArchiveWidget
 from lp.snappy.interfaces.snap import (
@@ -777,8 +779,6 @@
 
         discharge_macaroon = TextLine(
             title=u'Serialized discharge macaroon', required=True)
-        success_url = URIField(
-            title=u'URL to redirect to on success', allowed_schemes=['https'])
 
     render_context = False
 
@@ -792,7 +792,15 @@
     def requestAuthorization(cls, snap, request):
         """Begin the process of authorizing uploads of a snap package."""
         try:
-            return snap.beginAuthorization()
+            sso_caveat_id = snap.beginAuthorization()
+            base_url = canonical_url(snap, view_name='+authorize')
+            login_url = urlappend(base_url, '+login')
+            login_url += '?%s' % urlencode([
+                ('macaroon_caveat_id', sso_caveat_id),
+                ('discharge_macaroon_action', 'field.actions.complete'),
+                ('discharge_macaroon_field', 'field.discharge_macaroon'),
+                ])
+            return login_url
         except CannotAuthorizeStoreUploads as e:
             request.response.addInfoNotification(unicode(e))
             request.response.redirect(canonical_url(snap))
@@ -811,20 +819,11 @@
                 _(u'Uploads of %(snap)s to the store were not authorized.'),
                 snap=self.context.name))
             return
-        success_url = data.get('success_url')
-        # XXX cjwatson 2016-12-08: Drop this once all appservers have been
-        # upgraded to always pass success_url.
-        if success_url is None:
-            success_url = canonical_url(self.context)
-        # We have to set a whole new dict here to avoid problems with
-        # security proxies.
-        new_store_secrets = dict(self.context.store_secrets)
-        new_store_secrets['discharge'] = data['discharge_macaroon']
-        self.context.store_secrets = new_store_secrets
+        self.context.completeAuthorization(data['discharge_macaroon'])
         self.request.response.addInfoNotification(structured(
             _(u'Uploads of %(snap)s to the store are now authorized.'),
             snap=self.context.name))
-        self.request.response.redirect(success_url)
+        self.request.response.redirect(canonical_url(self.context))
 
     @property
     def adapters(self):

=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py	2016-12-08 17:39:19 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py	2016-12-15 20:23:14 +0000
@@ -11,7 +11,6 @@
     )
 import json
 import re
-from urllib import quote_plus
 from urllib2 import HTTPError
 from urlparse import (
     parse_qs,
@@ -433,7 +432,6 @@
             "discharge_macaroon_action": ["field.actions.complete"],
             "discharge_macaroon_field": ["field.discharge_macaroon"],
             "macaroon_caveat_id": ["dummy"],
-            "field.success_url": [canonical_url(snap, rootsite="code")],
             }
         self.assertEqual(expected_args, parse_qs(parsed_location[3]))
 
@@ -974,7 +972,6 @@
             "discharge_macaroon_action": ["field.actions.complete"],
             "discharge_macaroon_field": ["field.discharge_macaroon"],
             "macaroon_caveat_id": ["dummy"],
-            "field.success_url": [canonical_url(snap)],
             }
         self.assertEqual(expected_args, parse_qs(parsed_location[3]))
 
@@ -1049,8 +1046,7 @@
         self.assertEqual(
             snap_url + "/+authorize/+login?macaroon_caveat_id=dummy&"
             "discharge_macaroon_action=field.actions.complete&"
-            "discharge_macaroon_field=field.discharge_macaroon&"
-            "field.success_url=" + quote_plus(snap_url),
+            "discharge_macaroon_field=field.discharge_macaroon",
             redirection.hdrs["Location"])
 
     def test_complete_authorization_missing_discharge_macaroon(self):
@@ -1096,32 +1092,6 @@
                 {"root": "root", "discharge": "discharge"},
                 self.snap.store_secrets)
 
-    def test_complete_authorization_with_success_url(self):
-        # The "complete" action honours the provided success_url.
-        with person_logged_in(self.snap.owner):
-            self.snap.store_secrets = {"root": "root"}
-            transaction.commit()
-            form = {
-                "field.actions.complete": "1",
-                "field.discharge_macaroon": "discharge",
-                "field.success_url": "https://example.org/";,
-                }
-            view = create_initialized_view(
-                self.snap, "+authorize", form=form, method="POST",
-                principal=self.snap.owner)
-            self.assertEqual("", view())
-            self.assertEqual(302, view.request.response.getStatus())
-            self.assertEqual(
-                "https://example.org/";,
-                view.request.response.getHeader("Location"))
-            self.assertEqual(
-                "Uploads of %s to the store are now authorized." %
-                self.snap.name,
-                view.request.response.notifications[0].message)
-            self.assertEqual(
-                {"root": "root", "discharge": "discharge"},
-                self.snap.store_secrets)
-
 
 class TestSnapDeleteView(BrowserTestCase):
 

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2016-12-08 17:50:35 +0000
+++ lib/lp/snappy/interfaces/snap.py	2016-12-15 20:23:14 +0000
@@ -347,20 +347,17 @@
         :return: A sequence of `ISnapBuild` instances.
         """
 
-    @operation_parameters(
-        success_url=URIField(
-            title=_("URL to redirect to on success"),
-            allowed_schemes=["https"]))
     @export_write_operation()
     @operation_for_version("devel")
-    def beginAuthorization(success_url=None):
+    def beginAuthorization():
         """Begin authorizing uploads of this snap package to the store.
 
         This is intended for use by third-party sites integrating with
         Launchpad.  Most users should visit <snap URL>/+authorize instead.
 
         :param success_url: The URL to redirect to when authorization is
-            complete.  Defaults to the canonical URL of the snap.
+            complete.  If None (only allowed for internal use), defaults to
+            the canonical URL of the snap.
         :raises CannotAuthorizeStoreUploads: if the snap package is not
             properly configured for store uploads.
         :raises BadRequestPackageUploadResponse: if the store returns an
@@ -368,8 +365,27 @@
             package_upload macaroon.
         :raises SnapAuthorizationBadMacaroon: if the package_upload macaroon
             returned by the store has unsuitable SSO caveats.
-        :return: A URL that the third-party site should redirect the user to
-            in order to continue authorization.
+        :return: The SSO caveat ID from the package_upload macaroon returned
+            by the store.  The third-party site should acquire a discharge
+            macaroon for this caveat using OpenID and then call
+            `completeAuthorization`.
+        """
+
+    @operation_parameters(
+        discharge_macaroon=TextLine(
+            title=_("Serialized discharge macaroon"), required=True))
+    @export_write_operation()
+    @operation_for_version("devel")
+    def completeAuthorization(discharge_macaroon):
+        """Complete authorizing uploads of this snap package to the store.
+
+        This is intended for use by third-party sites integrating with
+        Launchpad.
+
+        :param discharge_macaroon: The serialized discharge macaroon
+            returned by SSO via OpenID.
+        :raises CannotAuthorizeStoreUploads: if the snap package is not
+            properly configured for store uploads.
         """
 
     @export_destructor_operation()

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2016-12-08 17:39:19 +0000
+++ lib/lp/snappy/model/snap.py	2016-12-15 20:23:14 +0000
@@ -10,7 +10,6 @@
     datetime,
     timedelta,
     )
-from urllib import urlencode
 from urlparse import urlsplit
 
 from pymacaroons import Macaroon
@@ -102,8 +101,6 @@
     )
 from lp.services.openid.adapters.openid import CurrentOpenIDEndPoint
 from lp.services.webapp.interfaces import ILaunchBag
-from lp.services.webapp.publisher import canonical_url
-from lp.services.webapp.url import urlappend
 from lp.services.webhooks.interfaces import IWebhookSet
 from lp.services.webhooks.model import WebhookTargetMixin
 from lp.snappy.interfaces.snap import (
@@ -369,7 +366,7 @@
                 "Macaroon has multiple SSO caveats")
         return sso_caveats[0]
 
-    def beginAuthorization(self, success_url=None):
+    def beginAuthorization(self):
         """See `ISnap`."""
         if self.store_series is None:
             raise CannotAuthorizeStoreUploads(
@@ -379,23 +376,21 @@
             raise CannotAuthorizeStoreUploads(
                 "Cannot authorize uploads of a snap package with no store "
                 "name.")
-        if success_url is None:
-            success_url = canonical_url(self)
         snap_store_client = getUtility(ISnapStoreClient)
         root_macaroon_raw = snap_store_client.requestPackageUploadPermission(
             self.store_series, self.store_name)
         sso_caveat = self.extractSSOCaveat(
             Macaroon.deserialize(root_macaroon_raw))
         self.store_secrets = {'root': root_macaroon_raw}
-        base_url = canonical_url(self, view_name='+authorize')
-        login_url = urlappend(base_url, '+login')
-        login_url += '?%s' % urlencode([
-            ('macaroon_caveat_id', sso_caveat.caveat_id),
-            ('discharge_macaroon_action', 'field.actions.complete'),
-            ('discharge_macaroon_field', 'field.discharge_macaroon'),
-            ('field.success_url', success_url),
-            ])
-        return login_url
+        return sso_caveat.caveat_id
+
+    def completeAuthorization(self, discharge_macaroon):
+        """See `ISnap`."""
+        if self.store_secrets is None or "root" not in self.store_secrets:
+            raise CannotAuthorizeStoreUploads(
+                "beginAuthorization must be called before "
+                "completeAuthorization.")
+        self.store_secrets["discharge"] = discharge_macaroon
 
     @property
     def can_upload_to_store(self):

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2016-12-08 17:39:19 +0000
+++ lib/lp/snappy/tests/test_snap.py	2016-12-15 20:23:14 +0000
@@ -10,7 +10,6 @@
     timedelta,
     )
 import json
-from urllib import quote_plus
 from urlparse import urlsplit
 
 from httmock import (
@@ -55,7 +54,6 @@
 from lp.services.log.logger import BufferLogger
 from lp.services.propertycache import clear_property_cache
 from lp.services.webapp.interfaces import OAuthPermission
-from lp.services.webapp.publisher import canonical_url
 from lp.snappy.interfaces.snap import (
     BadSnapSearchContext,
     CannotModifySnapProcessor,
@@ -1440,7 +1438,7 @@
                 }
             self.assertEqual(expected_body, json.loads(self.request.body))
             self.assertEqual({"root": root_macaroon_raw}, snap.store_secrets)
-        return response
+        return response, root_macaroon.third_party_caveats()[0]
 
     def test_beginAuthorization(self):
         with admin_logged_in():
@@ -1449,31 +1447,8 @@
             registrant=self.person, store_upload=True,
             store_series=snappy_series,
             store_name=self.factory.getUniqueUnicode())
-        snap_url = canonical_url(snap)
-        response = self.assertBeginsAuthorization(snap)
-        self.assertEqual(
-            snap_url + "/+authorize/+login?macaroon_caveat_id=dummy&"
-            "discharge_macaroon_action=field.actions.complete&"
-            "discharge_macaroon_field=field.discharge_macaroon&"
-            "field.success_url=" + quote_plus(snap_url),
-            response.jsonBody())
-
-    def test_beginAuthorization_with_success_url(self):
-        with admin_logged_in():
-            snappy_series = self.factory.makeSnappySeries()
-        snap = self.factory.makeSnap(
-            registrant=self.person, store_upload=True,
-            store_series=snappy_series,
-            store_name=self.factory.getUniqueUnicode())
-        snap_url = canonical_url(snap)
-        response = self.assertBeginsAuthorization(
-            snap, success_url="https://example.org/";)
-        self.assertEqual(
-            snap_url + "/+authorize/+login?macaroon_caveat_id=dummy&"
-            "discharge_macaroon_action=field.actions.complete&"
-            "discharge_macaroon_field=field.discharge_macaroon&"
-            "field.success_url=https%3A%2F%2Fexample.org%2F",
-            response.jsonBody())
+        response, sso_caveat = self.assertBeginsAuthorization(snap)
+        self.assertEqual(sso_caveat.caveat_id, response.jsonBody())
 
     def test_beginAuthorization_unauthorized(self):
         # A user without edit access cannot authorize snap package uploads.
@@ -1491,6 +1466,61 @@
         response = other_webservice.named_post(snap_url, "beginAuthorization")
         self.assertEqual(401, response.status)
 
+    def test_completeAuthorization(self):
+        with admin_logged_in():
+            snappy_series = self.factory.makeSnappySeries()
+        snap = self.factory.makeSnap(
+            registrant=self.person, store_upload=True,
+            store_series=snappy_series,
+            store_name=self.factory.getUniqueUnicode(),
+            store_secrets={"root": "dummy-root"})
+        snap_url = api_url(snap)
+        logout()
+        response = self.webservice.named_post(
+            snap_url, "completeAuthorization",
+            discharge_macaroon="dummy-discharge")
+        self.assertEqual(200, response.status)
+        self.assertEqual("", response.body)
+        with person_logged_in(self.person):
+            self.assertEqual(
+                {"root": "dummy-root", "discharge": "dummy-discharge"},
+                snap.store_secrets)
+
+    def test_completeAuthorization_without_beginAuthorization(self):
+        with admin_logged_in():
+            snappy_series = self.factory.makeSnappySeries()
+        snap = self.factory.makeSnap(
+            registrant=self.person, store_upload=True,
+            store_series=snappy_series,
+            store_name=self.factory.getUniqueUnicode())
+        snap_url = api_url(snap)
+        logout()
+        response = self.webservice.named_post(
+            snap_url, "completeAuthorization",
+            discharge_macaroon="dummy-discharge")
+        self.assertEqual(400, response.status)
+        self.assertEqual(
+            "beginAuthorization must be called before completeAuthorization.",
+            response.body)
+
+    def test_completeAuthorization_unauthorized(self):
+        with admin_logged_in():
+            snappy_series = self.factory.makeSnappySeries()
+        snap = self.factory.makeSnap(
+            registrant=self.person, store_upload=True,
+            store_series=snappy_series,
+            store_name=self.factory.getUniqueUnicode(),
+            store_secrets={"root": "dummy-root"})
+        snap_url = api_url(snap)
+        other_person = self.factory.makePerson()
+        other_webservice = webservice_for_person(
+            other_person, permission=OAuthPermission.WRITE_PUBLIC)
+        other_webservice.default_api_version = "devel"
+        response = other_webservice.named_post(
+            snap_url, "completeAuthorization",
+            discharge_macaroon="dummy-discharge")
+        self.assertEqual(401, response.status)
+
     def makeBuildableDistroArchSeries(self, **kwargs):
         das = self.factory.makeDistroArchSeries(**kwargs)
         fake_chroot = self.factory.makeLibraryFileAlias(


Follow ups